UNPKG

unified-video-framework

Version:

Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more

1,165 lines 383 kB
import { BasePlayer } from "../../core/dist/BasePlayer.js"; import { ChapterManager as CoreChapterManager } from "../../core/dist/index.js"; import { ChapterManager } from "./chapters/ChapterManager.js"; import YouTubeExtractor from "./utils/YouTubeExtractor.js"; export class WebPlayer extends BasePlayer { constructor() { super(...arguments); this.video = null; this.hls = null; this.dash = null; this.qualities = []; this.currentQualityIndex = -1; this.autoQuality = true; this.useCustomControls = true; this.controlsContainer = null; this.volumeHideTimeout = null; this.hideControlsTimeout = null; this.isVolumeSliding = false; this.availableQualities = []; this.availableSubtitles = []; this.currentQuality = 'auto'; this.currentSubtitle = 'off'; this.currentPlaybackRate = 1; this.isDragging = false; this.settingsConfig = { enabled: true, speed: true, quality: true, subtitles: true }; this.watermarkCanvas = null; this.playerWrapper = null; this.flashTickerContainer = null; this.flashTickerTopElement = null; this.flashTickerBottomElement = null; this.previewGateHit = false; this.paymentSuccessTime = 0; this.paymentSuccessful = false; this.isPaywallActive = false; this.authValidationInterval = null; this.overlayRemovalAttempts = 0; this.maxOverlayRemovalAttempts = 3; this.lastSecurityCheck = 0; this.castContext = null; this.remotePlayer = null; this.remoteController = null; this.isCasting = false; this._castTrackIdByKey = {}; this.selectedSubtitleKey = 'off'; this._kiTo = null; this.paywallController = null; this._playPromise = null; this._deferredPause = false; this._lastToggleAt = 0; this._TOGGLE_DEBOUNCE_MS = 120; this.hasTriedButtonFallback = false; this.lastUserInteraction = 0; this.showTimeTooltip = false; this.tapStartTime = 0; this.tapStartX = 0; this.tapStartY = 0; this.lastTapTime = 0; this.lastTapX = 0; this.tapCount = 0; this.longPressTimer = null; this.isLongPressing = false; this.longPressPlaybackRate = 1; this.tapResetTimer = null; this.fastBackwardInterval = null; this.handleSingleTap = () => { }; this.handleDoubleTap = () => { }; this.handleLongPress = () => { }; this.handleLongPressEnd = () => { }; this.autoplayCapabilities = { canAutoplay: false, canAutoplayMuted: false, canAutoplayUnmuted: false, lastCheck: 0 }; this.autoplayRetryPending = false; this.autoplayRetryAttempts = 0; this.maxAutoplayRetries = 3; this.chapterManager = null; this.hasAppliedStartTime = false; this.coreChapterManager = null; this.chapterConfig = { enabled: false }; this.qualityFilter = null; this.premiumQualities = null; this.youtubeNativeControls = true; this.isAdPlaying = false; this.fallbackSourceIndex = -1; this.fallbackErrors = []; this.isLoadingFallback = false; this.currentRetryAttempt = 0; this.lastFailedUrl = ''; this.isFallbackPosterMode = false; this.autoplayAttempted = false; this.youtubePlayer = null; this.youtubePlayerReady = false; this.youtubeIframe = null; this.youtubeTimeTrackingInterval = null; this.clickToUnmuteHandler = null; } debugLog(message, ...args) { if (this.config.debug) { console.log(`[WebPlayer] ${message}`, ...args); } } debugError(message, ...args) { if (this.config.debug) { console.error(`[WebPlayer] ${message}`, ...args); } } debugWarn(message, ...args) { if (this.config.debug) { console.warn(`[WebPlayer] ${message}`, ...args); } } async initialize(container, config) { console.log('WebPlayer.initialize called with config:', config); if (config) { if (config.customControls !== undefined) { this.useCustomControls = config.customControls; console.log('[WebPlayer] Custom controls set to:', this.useCustomControls); } else if (config.controls !== undefined) { this.useCustomControls = config.controls; console.log('[WebPlayer] Controls set to:', this.useCustomControls); } } 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); } 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'); } if (config && config.qualityFilter) { console.log('Quality filter config found:', config.qualityFilter); this.qualityFilter = config.qualityFilter; } if (config && config.premiumQualities) { console.log('Premium qualities config found:', config.premiumQualities); this.premiumQualities = config.premiumQualities; } if (config && config.youtubeNativeControls !== undefined) { console.log('YouTube native controls config found:', config.youtubeNativeControls); this.youtubeNativeControls = config.youtubeNativeControls; } await super.initialize(container, config); } async setupPlayer() { if (!this.container) { throw new Error('Container element is required'); } this.injectStyles(); const wrapper = document.createElement('div'); wrapper.className = 'uvf-player-wrapper'; this.playerWrapper = wrapper; const videoContainer = document.createElement('div'); videoContainer.className = 'uvf-video-container'; this.video = document.createElement('video'); this.video.className = 'uvf-video'; this.video.controls = false; this.video.autoplay = false; 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'; this.video.webkitAllowsAirPlay = true; this.video.setAttribute('x-webkit-airplay', 'allow'); if (this.config.crossOrigin) { this.video.crossOrigin = this.config.crossOrigin; } this.watermarkCanvas = document.createElement('canvas'); this.watermarkCanvas.className = 'uvf-watermark-layer'; 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; `; videoContainer.appendChild(this.video); videoContainer.appendChild(this.watermarkCanvas); videoContainer.appendChild(this.flashTickerContainer); this.createCustomControls(videoContainer); if (!this.useCustomControls) { wrapper.classList.add('controls-disabled'); } wrapper.appendChild(videoContainer); this.container.innerHTML = ''; this.container.appendChild(wrapper); this.applyScrollbarPreferencesFromDataset(); this.setupVideoEventListeners(); this.setupControlsEventListeners(); this.setupKeyboardShortcuts(); this.setupWatermark(); this.setupFullscreenListeners(); this.setupUserInteractionTracking(); if (this.chapterConfig.enabled && this.video) { this.setupChapterManager(); } try { const pw = this.config.paywall || null; if (pw && pw.enabled) { const { PaywallController } = await import('./paywall/PaywallController'); this.paywallController = new PaywallController(pw, { getOverlayContainer: () => this.playerWrapper, onResume: () => { try { this.debugLog('onResume callback triggered - payment/auth successful'); this.previewGateHit = false; this.paymentSuccessTime = Date.now(); this.paymentSuccessful = true; this.isPaywallActive = false; this.overlayRemovalAttempts = 0; if (this.authValidationInterval) { this.debugLog('Clearing security monitoring interval'); clearInterval(this.authValidationInterval); this.authValidationInterval = null; } this.forceCleanupOverlays(); this.debugLog('Payment successful - all security restrictions lifted, resuming playback'); setTimeout(() => { this.play(); }, 150); } catch (error) { this.debugError('Error in onResume callback:', error); } }, onShow: () => { this.isPaywallActive = true; this.startOverlayMonitoring(); try { this.requestPause(); } catch (_) { } }, onClose: () => { this.debugLog('onClose callback triggered - paywall closing'); this.isPaywallActive = false; if (this.authValidationInterval) { this.debugLog('Clearing security monitoring interval on close'); clearInterval(this.authValidationInterval); this.authValidationInterval = null; } this.overlayRemovalAttempts = 0; } }); this.on('onFreePreviewEnded', () => { this.debugLog('onFreePreviewEnded event triggered, calling paywallController.openOverlay()'); try { this.paywallController?.openOverlay(); } catch (error) { this.debugError('Error calling paywallController.openOverlay():', error); } }); } } catch (_) { } this.setupCastContextSafe(); this.updateMetadataUI(); } setupVideoEventListeners() { if (!this.video) return; this.video.addEventListener('play', () => { 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', () => { if (this._deferredPause) { this._deferredPause = false; try { this.video?.pause(); } catch (_) { } } 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); this.emit('onTimeUpdate', t); 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`); } this.enforceFreePreviewGate(t); 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'); if (this.isLoadingFallback) { this.debugLog('✅ Fallback source loaded successfully!'); this.isLoadingFallback = false; this.lastFailedUrl = ''; } if (this.isFallbackPosterMode) { this.debugLog('✅ Exiting fallback poster mode - video source loaded'); this.isFallbackPosterMode = false; const posterOverlay = this.playerWrapper?.querySelector('#uvf-fallback-poster'); if (posterOverlay) { posterOverlay.remove(); } if (this.video) { this.video.style.display = ''; } } this.setBuffering(false); this.emit('onReady'); this.updateTimeDisplay(); if (this._deferredPause) { this._deferredPause = false; try { this.video?.pause(); } catch (_) { } } 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); this.updateTimeDisplay(); 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 || ''; 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}`); 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!'); return; } this.debugLog('❌ All fallbacks failed'); } else { this.debugLog('❌ No fallback sources available or already loading'); } 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', () => { if (!this.video) return; const t = this.video.currentTime || 0; this.enforceFreePreviewGate(t, true); this.emit('onSeeked'); }); } getMediaErrorMessage(code) { 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'; } } updateBufferProgress() { 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) { this.source = source; this.subtitles = (source.subtitles || []); this.debugLog('Loading video source:', source.url); this.debugLog('Fallback sources provided:', source.fallbackSources); this.debugLog('Fallback poster provided:', source.fallbackPoster); this.autoplayAttempted = false; this.hasAppliedStartTime = false; this.fallbackSourceIndex = -1; this.fallbackErrors = []; this.isLoadingFallback = false; this.currentRetryAttempt = 0; this.lastFailedUrl = ''; this.isFallbackPosterMode = false; await this.cleanup(); if (!this.video) { throw new Error('Video element not initialized'); } const sourceType = this.detectSourceType(source); try { await this.loadVideoSource(source.url, sourceType, source); } catch (error) { 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; } } } async loadVideoSource(url, sourceType, source) { 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); } if (source.subtitles && source.subtitles.length > 0) { this.loadSubtitles(source.subtitles); } if (source.metadata) { if (source.metadata.posterUrl && this.video) { this.video.poster = source.metadata.posterUrl; } this.updateMetadataUI(); } else { this.updateMetadataUI(); } } async tryFallbackSource(error) { 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; 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); const isMainUrl = this.fallbackSourceIndex === -1; const maxRetries = this.source.fallbackRetryAttempts || 1; if (!isMainUrl && this.currentRetryAttempt < maxRetries) { 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); this.debugLog(`Retry initiated for: ${currentUrl} - waiting for load confirmation...`); } catch (retryError) { this.debugLog(`Retry failed for: ${currentUrl}`, retryError); } } 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`); } } this.currentRetryAttempt = 0; this.fallbackSourceIndex++; if (this.fallbackSourceIndex >= this.source.fallbackSources.length) { this.isLoadingFallback = false; this.debugLog('All video sources failed. Attempting to show fallback poster.'); if (this.source.onAllSourcesFailed) { try { this.source.onAllSourcesFailed(this.fallbackErrors); } catch (callbackError) { console.error('Error in onAllSourcesFailed callback:', callbackError); } } return this.showFallbackPoster(); } 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; return this.tryFallbackSource(fallbackError); } } showFallbackPoster() { if (!this.source?.fallbackPoster) { this.debugLog('No fallback poster available'); return false; } this.debugLog('Showing fallback poster:', this.source.fallbackPoster); this.isFallbackPosterMode = true; this.debugLog('✅ Fallback poster mode activated - playback disabled'); if (this.video) { this.video.style.display = 'none'; this.video.poster = this.source.fallbackPoster; this.video.removeAttribute('src'); this.video.load(); } 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; `; const showErrorMessage = this.source.fallbackShowErrorMessage !== false; 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); } const existingPoster = this.playerWrapper?.querySelector('#uvf-fallback-poster'); if (existingPoster) { existingPoster.remove(); } if (this.playerWrapper) { this.playerWrapper.appendChild(posterOverlay); } this.showNotification('Video unavailable'); return true; } detectSourceType(source) { if (source.type && source.type !== 'auto') { return source.type; } const url = source.url.toLowerCase(); 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'; } async loadHLS(url) { 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, data) => { this.qualities = data.levels.map((level, index) => ({ height: level.height, width: level.width || 0, bitrate: level.bitrate, label: `${level.height}p`, index: index })); this.updateSettingsMenu(); if (this.qualityFilter || (this.premiumQualities && this.premiumQualities.enabled)) { this.debugLog('Applying quality filter on HLS manifest load'); this.applyHLSQualityFilter(); } }); this.hls.on(window.Hls.Events.LEVEL_SWITCHED, (event, data) => { if (this.qualities[data.level]) { this.currentQualityIndex = data.level; this.state.currentQuality = this.qualities[data.level]; this.emit('onQualityChanged', this.qualities[data.level]); } }); this.hls.on(window.Hls.Events.ERROR, (event, data) => { if (data.fatal) { this.handleHLSError(data); } }); } else if (this.video.canPlayType('application/vnd.apple.mpegurl')) { this.video.src = url; } else { throw new Error('HLS is not supported in this browser'); } } handleHLSError(data) { 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; } } async loadDASH(url) { 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); this.dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: this.config.enableAdaptiveBitrate ?? true, audio: true } }, buffer: { fastSwitchEnabled: true } } }); this.dash.on(window.dashjs.MediaPlayer.events.QUALITY_CHANGE_RENDERED, (e) => { if (e.mediaType === 'video') { this.updateDASHQuality(e.newQuality); } }); 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, index) => ({ height: info.height || 0, width: info.width || 0, bitrate: info.bitrate, label: `${info.height}p`, index: index })); this.updateSettingsMenu(); if (this.qualityFilter || (this.premiumQualities && this.premiumQualities.enabled)) { this.debugLog('Applying quality filter on DASH stream initialization'); this.applyDASHQualityFilter(); } } }); this.dash.on(window.dashjs.MediaPlayer.events.ERROR, (e) => { this.handleError({ code: 'DASH_ERROR', message: e.error.message, type: 'media', fatal: true, details: e }); }); } updateDASHQuality(qualityIndex) { if (this.qualities[qualityIndex]) { this.currentQualityIndex = qualityIndex; this.state.currentQuality = this.qualities[qualityIndex]; this.emit('onQualityChanged', this.qualities[qualityIndex]); } } async loadNative(url) { if (!this.video) return; this.video.src = url; this.video.load(); } async loadYouTube(url, source) { try { this.debugLog('Loading YouTube video:', url); const videoId = YouTubeExtractor.extractVideoId(url); if (!videoId) { throw new Error('Invalid YouTube URL'); } const metadata = await YouTubeExtractor.getVideoMetadata(url); 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 } }; if (this.video && metadata.thumbnail) { this.video.poster = metadata.thumbnail; } 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}`); } } async createYouTubePlayer(videoId) { const container = this.playerWrapper || this.video?.parentElement; if (!container) { throw new Error('No container found for YouTube player'); } if (this.video) { this.video.style.display = 'none'; } 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; `; const existingPlayer = container.querySelector(`#youtube-player-${videoId}`); if (existingPlayer) { existingPlayer.remove(); } container.appendChild(iframeContainer); if (!window.YT) { await this.loadYouTubeAPI(); } await this.waitForYouTubeAPI(); this.youtubePlayer = new window.YT.Player(iframeContainer.id, { videoId: videoId, width: '100%', height: '100%', playerVars: { controls: this.youtubeNativeControls ? 1 : 0, disablekb: 0, fs: this.youtubeNativeControls ? 1 : 0, iv_load_policy: 3, modestbranding: 1, rel: 0, showinfo: 0, autoplay: this.config.autoPlay ? 1 : 0, mute: this.config.muted ? 1 : 0, loop: this.config.loop ? 1 : 0, playlist: this.config.loop ? videoId : undefined, widget_referrer: window.location.href }, events: { onReady: () => this.onYouTubePlayerReady(), onStateChange: (event) => this.onYouTubePlayerStateChange(event), onError: (event) => this.onYouTubePlayerError(event) } }); this.debugLog('YouTube player created'); } async loadYouTubeAPI() { return new Promise((resolve) => { if (window.YT) { resolve(); return; } window.onYouTubeIframeAPIReady = () => { this.debugLog('YouTube IFrame API loaded'); resolve(); }; const script = document.createElement('script'); script.src = 'https://www.youtube.com/iframe_api'; script.async = true; document.body.appendChild(script); }); } async waitForYouTubeAPI() { return new Promise((resolve) => { const checkAPI = () => { if (window.YT && window.YT.Player) { resolve(); } else { setTimeout(checkAPI, 100); } }; checkAPI(); }); } onYouTubePlayerReady() { this.youtubePlayerReady = true; this.debugLog('YouTube player ready'); if (this.youtubeNativeControls && this.playerWrapper) { this.debugLog('[YouTube] Native controls enabled - hiding custom controls'); this.playerWrapper.classList.add('youtube-native-controls-mode'); this.hideCustomControls(); } if (this.youtubePlayer) { const volume = this.config.volume ? this.config.volume * 100 : 100; this.youtubePlayer.setVolume(volume); if (this.config.muted) { this.youtubePlayer.mute(); } 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; } 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); } } } this.startYouTubeTimeTracking(); this.emit('onReady'); } onYouTubePlayerStateChange(event) { 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'); 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; } } updateYouTubeUI(state) { const playIcon = document.getElementById('uvf-play-icon'); const pauseIcon = document.getElementById('uvf-pause-icon'); const centerPlay = document.getElementById('uvf-center-play'); if (state === 'playing' || state === 'buffering') { if (playIcon) playIcon.style.display = 'none'; if (pauseIcon) pauseIcon.style.display = 'block'; if (centerPlay) centerPlay.classList.add('hidden'); } else if (state === 'paused' || state === 'cued' || state === 'ended') { if (playIcon) playIcon.style.display = 'block'; if (pauseIcon) pauseIcon.style.display = 'none'; if (centerPlay) centerPlay.classList.remove('hidden'); } } onYouTubePlayerError(event) { const errorCode = event.data; let errorMessage = 'YouTube player error'; switch (errorCode) { case 2: errorMessage = 'Invalid video ID'; break; case 5: errorMessage = 'HTML5 player error'; break; case 100: errorMessage = 'Video not found or private'; break; case 101: case 150: errorMessage = 'Video cannot be embedded'; break; } this.handleError({ code: 'YOUTUBE_ERROR', message: errorMessage, type: 'media', fatal: true, details: { errorCode } }); } hideCustomControls() { const controlsBar = document.getElementById('uvf-controls'); if (controlsBar) { controlsBar.style.display = 'none'; controlsBar.style.visibility = 'hidden'; controlsBar.style.opacity = '0'; controlsBar.style.pointerEvents = 'none'; } const topBar = document.querySelector('.uvf-top-bar'); if (topBar) { topBar.style.display = 'none'; topBar.style.visibility = 'hidden'; topBar.style.opacity = '0'; topBar.style.pointerEvents = 'none'; } const centerPlayContainer = document.querySelector('.uvf-center-play-container'); if (centerPlayContainer) { centerPlayContainer.style.display = 'none'; centerPlayContainer.style.visibility = 'hidden'; centerPlayContainer.style.opacity = '0'; centerPlayContainer.style.pointerEvents = 'none'; } } startYouTubeTimeTracking() { if (this.youtubeTimeTrackingInterval) { clearInterval(this.youtubeTimeTrackingInterval); } this.youtubeTimeTrackingInterval = setInterval(() => { if (this.youtubePlayer && this.youtubePlayerReady) { try { const currentTime = this.youtubePlayer.getCurrentTime(); const duration = this.youtubePlayer.getDuration(); const buffered = this.youtubePlayer.getVideoLoadedFraction() * 100; this.state.currentTime = currentTime || 0; this.state.duration = duration || 0; this.state.bufferedPercentage = buffered || 0; this.updateYouTubeProgressBar(currentTime, duration, buffered); this.emit('onTimeUpdate', this.state.currentTime); this.emit('onProgress', this.state.bufferedPercentage); } catch (error) { } } }, 250); } updateYouTubeProgressBar(currentTime, duration, buffered) { if (!duration || duration === 0) return; const percent = (currentTime / duration) * 100; const progressFilled = document.getElementById('uvf-progress-filled'); if (progressFilled && !this.isDragging) { progressFilled.style.width = percent + '%'; } const progressHandle = document.getElementByI