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
JavaScript
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