UNPKG

unified-video-framework

Version:

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

1,415 lines (1,204 loc) 549 kB
/** * Web implementation of the video player with HLS and DASH support */ import { BasePlayer } from '../../core/dist/BasePlayer'; import { VideoSource, PlayerConfig, ControlsVisibilityConfig, 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, FlashNewsTickerItem, BroadcastStyleConfig, BroadcastTheme, TickerStyleVariant, TickerLayoutStyle, IntroAnimationType, TwoLineDisplayConfig, DecorativeShapesConfig, ItemCyclingConfig, SeparatorType } from './react/types/FlashNewsTickerTypes'; import type { ThumbnailPreviewConfig, ThumbnailEntry, ThumbnailGenerationImage } from './react/types/ThumbnailPreviewTypes'; import YouTubeExtractor from './utils/YouTubeExtractor'; import { SrtConverter } from './utils/SrtConverter'; import { DRMManager, DRMErrorHandler } from './drm'; import type { DRMInitResult, DRMError } from './drm'; // 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 currentStreamType: string = ''; 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; // Event handler references for cleanup private globalMouseMoveHandler: ((e: MouseEvent) => void) | null = null; private globalTouchMoveHandler: ((e: TouchEvent) => void) | null = null; private globalMouseUpHandler: (() => void) | null = null; private globalTouchEndHandler: (() => void) | null = null; // 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 }; // Controls visibility configuration private controlsVisibility!: ControlsVisibilityConfig; private watermarkCanvas: HTMLCanvasElement | null = null; private playerWrapper: HTMLElement | null = null; // Multi-instance support private instanceId: string = ''; private static instanceCounter: number = 0; private cachedElements: { loading?: HTMLElement | null; progressBar?: HTMLElement | null; progressFilled?: HTMLElement | null; progressHandle?: HTMLElement | null; progressBuffered?: HTMLElement | null; playIcon?: HTMLElement | null; pauseIcon?: HTMLElement | null; centerPlay?: HTMLElement | null; timeDisplay?: HTMLElement | null; thumbnailPreview?: HTMLElement | null; thumbnailImage?: HTMLImageElement | null; thumbnailTime?: HTMLElement | null; timeTooltip?: HTMLElement | null; settingsMenu?: HTMLElement | null; fullscreenBtn?: HTMLElement | null; } = {}; // Flash News Ticker private flashTickerContainer: HTMLDivElement | null = null; private flashTickerTopElement: HTMLDivElement | null = null; private flashTickerBottomElement: HTMLDivElement | null = null; // Professional Ticker State (for item cycling and intro animations) private tickerCurrentItemIndex: number = 0; private tickerCycleTimer: number | null = null; private tickerConfig: FlashNewsTickerConfig | null = null; private tickerHeadlineElement: HTMLDivElement | null = null; private tickerDetailElement: HTMLDivElement | null = null; private tickerIntroOverlay: HTMLDivElement | null = null; private tickerProgressBar: HTMLDivElement | null = null; private tickerProgressFill: HTMLDivElement | null = null; private tickerIsPaused: boolean = false; private tickerPauseStartTime: number = 0; private tickerRemainingTime: number = 0; // 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; // DRM management private drmManager: DRMManager | null = null; private isDRMProtected: boolean = false; // 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) private hlsErrorRetryCount: number = 0; // Track HLS error recovery attempts private readonly MAX_HLS_ERROR_RETRIES: number = 3; // Max HLS recovery attempts before fallback // Live stream detection private lastDuration: number = 0; // Track duration changes to detect live streams private isDetectedAsLive: boolean = false; // Once detected as live, stays live // Live stream waiting state private isWaitingForLiveStream: boolean = false; private liveRetryTimer: NodeJS.Timeout | null = null; private liveRetryAttempts: number = 0; // Bandwidth detection system private bandwidthDetector: { estimatedBandwidth: number | null; detectionComplete: boolean; detectionMethod: 'probe' | 'manifest' | 'fallback'; } = { estimatedBandwidth: null, detectionComplete: false, detectionMethod: 'fallback' }; private readonly BANDWIDTH_TIERS = { HIGH: 5_000_000, // 5 Mbps - High quality settings MEDIUM: 2_500_000, // 2.5 Mbps - Balanced settings LOW: 1_000_000 // 1 Mbps - Conservative settings }; private liveStreamOriginalSource: VideoSource | null = null; private liveMessageRotationTimer: NodeJS.Timeout | null = null; private liveMessageIndex: number = 0; private liveBufferingTimeoutTimer: NodeJS.Timeout | null = null; // Live countdown state private liveCountdownTimer: NodeJS.Timeout | null = null; private liveCountdownRemainingSeconds: number = 0; private isShowingLiveCountdown: boolean = false; private hasCountdownCompleted: boolean = false; // Prevent showing countdown again after completion private isReloadingAfterCountdown: boolean = false; // Prevent race conditions during reload private countdownCallbackFired: boolean = false; // Prevent double callback execution // Thumbnail preview private thumbnailPreviewConfig: ThumbnailPreviewConfig | null = null; private thumbnailEntries: ThumbnailEntry[] = []; private preloadedThumbnails: Map<string, HTMLImageElement> = new Map(); private currentThumbnailUrl: string | null = null; private thumbnailPreviewEnabled: boolean = false; private subtitleBlobUrls: string[] = []; private subtitleOverlay: HTMLElement | null = null; // 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); } } // ============================================ // Live Stream Waiting Methods // ============================================ /** * Check if error indicates live stream is not ready yet (vs fatal error) */ private isLiveStreamNotReady(error?: any): boolean { if (!this.config.isLive || !this.video) return false; // For live streams, treat ANY error as "not ready yet" // The retry mechanism will handle it - if it never becomes available, // the max retry limit will eventually show a proper error if (this.video.error) { this.debugLog(`Live stream error detected (code: ${this.video.error.code}), treating as "not ready"`); return true; } // Also check for invalid duration after metadata loads if (this.video.readyState >= 1) { // HAVE_METADATA or higher if (this.video.duration === 0 || !isFinite(this.video.duration)) { this.debugLog('Live stream has invalid duration, treating as "not ready"'); return true; } } return false; } /** * Enter live stream waiting mode */ private startLiveStreamWaiting(): void { if (this.isWaitingForLiveStream) { this.debugLog(`⏭️ Already in waiting mode, retry count: ${this.liveRetryAttempts}`); // Still need to check if max retries reached this.scheduleLiveStreamRetry(); return; // Already waiting } this.debugLog('🔴 Entering live stream waiting mode'); this.isWaitingForLiveStream = true; this.liveRetryAttempts = 0; // Clear buffering timeout timer since we're now in retry mode if (this.liveBufferingTimeoutTimer) { clearTimeout(this.liveBufferingTimeoutTimer); this.liveBufferingTimeoutTimer = null; } // Store original source for retries if (this.source) { this.liveStreamOriginalSource = this.source as any; } // Show waiting UI with rotating messages this.showLiveWaitingUI(); // Emit callback this.emit('onLiveStreamWaiting'); // Schedule first retry this.scheduleLiveStreamRetry(); } /** * Exit live stream waiting mode and clear all timers */ private stopLiveStreamWaiting(success: boolean): void { if (!this.isWaitingForLiveStream) return; this.debugLog(`🟢 Exiting live stream waiting mode (success: ${success})`); // Clear rotation timer if (this.liveMessageRotationTimer) { clearInterval(this.liveMessageRotationTimer); this.liveMessageRotationTimer = null; } // Clear retry timer if (this.liveRetryTimer) { clearTimeout(this.liveRetryTimer); this.liveRetryTimer = null; } // Hide UI const loading = this.cachedElements.loading; if (loading) { loading.classList.remove('with-message'); const messageEl = loading.querySelector('.uvf-loading-message') as HTMLElement; if (messageEl) { messageEl.textContent = ''; } } // Reset state this.isWaitingForLiveStream = false; this.liveRetryAttempts = 0; this.liveStreamOriginalSource = null; this.liveMessageIndex = 0; // Emit callback if successful if (success) { this.emit('onLiveStreamReady'); } } /** * Schedule next retry attempt */ private scheduleLiveStreamRetry(): void { // Check max retries const maxRetries = this.config.liveMaxRetryAttempts; this.debugLog(`🔍 Checking retries: current=${this.liveRetryAttempts}, max=${maxRetries}`); if (maxRetries !== undefined && this.liveRetryAttempts >= maxRetries) { this.debugLog(`❌ Max retry attempts (${maxRetries}) reached, stopping`); this.stopLiveStreamWaiting(false); // Emit callback to notify app that stream is unavailable this.debugLog('📢 Emitting onLiveStreamUnavailable event'); this.emit('onLiveStreamUnavailable', { reason: 'Max retry attempts exceeded', attempts: this.liveRetryAttempts }); // Show error UI this.handleError({ code: 'LIVE_STREAM_UNAVAILABLE', message: 'Live stream is not currently broadcasting', type: 'network', fatal: true }); return; } const retryInterval = this.config.liveRetryInterval || 5000; this.liveRetryAttempts++; this.debugLog(`Scheduling live retry #${this.liveRetryAttempts} in ${retryInterval}ms`); this.liveRetryTimer = setTimeout(() => { this.retryLiveStreamLoad(); }, retryInterval); } /** * Attempt to reload the live stream */ private async retryLiveStreamLoad(): Promise<void> { if (!this.isWaitingForLiveStream || !this.liveStreamOriginalSource) return; this.debugLog(`🔄 Retry attempt #${this.liveRetryAttempts} for live stream`); try { // Attempt to reload the source await this.load(this.liveStreamOriginalSource); // Ensure loading UI is still visible after retry (in case cleanup removed it) if (this.isWaitingForLiveStream) { const loading = this.cachedElements.loading; if (loading && !loading.classList.contains('active')) { loading.classList.add('active', 'with-message'); } } // If load succeeds and canplay fires, stopLiveStreamWaiting will be called automatically } catch (error) { this.debugLog('Retry failed, scheduling next attempt', error); // Schedule next retry this.scheduleLiveStreamRetry(); } } /** * Display waiting UI and start message rotation */ private showLiveWaitingUI(): void { const loading = this.cachedElements.loading; if (!loading) return; loading.classList.add('active', 'with-message'); // Show first message const messages = this.getLiveWaitingMessages(); this.liveMessageIndex = 0; this.updateLiveWaitingMessage(messages[0]); // Start rotation timer const rotationInterval = this.config.liveMessageRotationInterval || 2500; if (this.liveMessageRotationTimer) { clearInterval(this.liveMessageRotationTimer); } this.liveMessageRotationTimer = setInterval(() => { this.rotateLoadingMessage(); }, rotationInterval); } /** * Update the loading message text */ private updateLiveWaitingMessage(text: string): void { const loading = this.cachedElements.loading; if (!loading) return; const messageEl = loading.querySelector('.uvf-loading-message') as HTMLElement; if (messageEl) { messageEl.textContent = text; } } /** * Get array of messages for rotation */ private getLiveWaitingMessages(): string[] { const messages = this.config.liveWaitingMessages || {}; return [ messages.waitingForStream || 'Waiting for Stream', messages.loading || 'Loading', messages.comingBack || 'Coming back', ]; } /** * Rotate to next message in sequence */ private rotateLoadingMessage(): void { if (!this.isWaitingForLiveStream) return; const messages = this.getLiveWaitingMessages(); this.liveMessageIndex = (this.liveMessageIndex + 1) % messages.length; this.updateLiveWaitingMessage(messages[this.liveMessageIndex]); } // ============================================ // Live Countdown Methods // ============================================ /** * Show countdown timer for live streams that haven't started yet */ private showLiveCountdown(): void { const countdown = this.config.liveCountdown; if (!countdown?.nextProgramStartTime) { this.debugLog('⚠️ showLiveCountdown called but no nextProgramStartTime configured'); return; } // CRITICAL: Check if countdown has already completed - don't show again if (this.hasCountdownCompleted) { this.debugLog('⏱️ showLiveCountdown called but countdown already completed - skipping'); return; } // Calculate remaining seconds from UTC timestamp const remainingMs = countdown.nextProgramStartTime - Date.now(); this.liveCountdownRemainingSeconds = Math.max(0, Math.floor(remainingMs / 1000)); this.debugLog(`⏱️ Starting countdown with ${this.liveCountdownRemainingSeconds} seconds remaining (hasCountdownCompleted=${this.hasCountdownCompleted})`); // If countdown is already at zero or negative, complete immediately if (this.liveCountdownRemainingSeconds <= 0) { this.handleCountdownComplete(); return; } // Show countdown UI const loading = this.cachedElements.loading; if (!loading) return; this.isShowingLiveCountdown = true; loading.classList.add('active', 'uvf-countdown-mode'); // Hide center play button during countdown const centerPlayBtn = this.playerWrapper?.querySelector('.uvf-center-play-btn') as HTMLElement; if (centerPlayBtn) { centerPlayBtn.style.display = 'none'; } // Update display this.updateLiveCountdownDisplay(); // Start countdown timer const updateInterval = countdown.updateInterval || 1000; if (this.liveCountdownTimer) { clearInterval(this.liveCountdownTimer); } this.liveCountdownTimer = setInterval(() => { this.tickCountdown(); }, updateInterval); } /** * Sanitize HTML to prevent XSS attacks */ private sanitizeHtml(str: string): string { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } /** * Update the countdown display with current time remaining */ private updateLiveCountdownDisplay(): void { const loading = this.cachedElements.loading; if (!loading) return; const messageEl = loading.querySelector('.uvf-loading-message') as HTMLElement; if (!messageEl) return; const countdown = this.config.liveCountdown; const noProgramMsg = this.sanitizeHtml(countdown?.noProgramMessage || 'There is no active program in this channel.'); const countdownMsg = this.sanitizeHtml(countdown?.countdownMessage || 'Next program will start in:'); const timerColor = this.sanitizeHtml(countdown?.timerColor || 'var(--uvf-accent-color, #00d4ff)'); const formattedTime = this.sanitizeHtml(this.formatCountdownTime(this.liveCountdownRemainingSeconds)); messageEl.innerHTML = ` <div style="font-size: 18px; margin-bottom: 16px; font-weight: 500;">${noProgramMsg}</div> <div style="font-size: 16px; margin-bottom: 8px;">${countdownMsg}</div> <div style="font-size: 20px; font-weight: bold; color: ${timerColor};">${formattedTime}</div> `; } /** * Tick the countdown timer down by one second * Recalculates from timestamp to prevent drift */ private tickCountdown(): void { // Recalculate from actual timestamp to avoid drift (browser throttling, system load, etc.) const nextProgramTime = this.config.liveCountdown?.nextProgramStartTime; if (!nextProgramTime) { this.handleCountdownComplete(); return; } const remainingMs = nextProgramTime - Date.now(); this.liveCountdownRemainingSeconds = Math.max(0, Math.floor(remainingMs / 1000)); if (this.liveCountdownRemainingSeconds <= 0) { this.handleCountdownComplete(); } else { this.updateLiveCountdownDisplay(); } } /** * Handle countdown completion */ private handleCountdownComplete(): void { this.debugLog('⏱️ Countdown complete!'); // Mark countdown as completed to prevent showing again on reload this.hasCountdownCompleted = true; this.debugLog(`⏱️ Set hasCountdownCompleted = true`); // Stop countdown timer this.stopLiveCountdown(); // Fire callback only once (prevent double execution) if (this.config.liveCountdown?.onCountdownComplete && !this.countdownCallbackFired) { this.countdownCallbackFired = true; this.debugLog('⏱️ Firing onCountdownComplete callback'); this.config.liveCountdown.onCountdownComplete(); } // Auto-reload stream if enabled (default: true) const autoReload = this.config.liveCountdown?.autoReloadOnComplete !== false; if (autoReload && this.source && !this.isReloadingAfterCountdown) { this.isReloadingAfterCountdown = true; this.debugLog('🔄 Auto-reloading stream after countdown'); // Reset autoplay flag to allow autoplay on reload this.autoplayAttempted = false; // Use the proper load method to reload the stream // This ensures proper HLS/DASH detection and settings button visibility this.load(this.source) .catch((error) => { this.debugError('Failed to reload stream after countdown:', error); }) .finally(() => { this.isReloadingAfterCountdown = false; this.debugLog('🔄 Stream reload after countdown complete'); }); } } /** * Stop and cleanup countdown timer */ private stopLiveCountdown(): void { if (!this.isShowingLiveCountdown) return; this.debugLog('⏱️ Stopping countdown'); // Clear timer if (this.liveCountdownTimer) { clearInterval(this.liveCountdownTimer); this.liveCountdownTimer = null; } // Reset state this.isShowingLiveCountdown = false; this.liveCountdownRemainingSeconds = 0; // Clean up UI const loading = this.cachedElements.loading; if (loading) { loading.classList.remove('uvf-countdown-mode', 'active'); const messageEl = loading.querySelector('.uvf-loading-message') as HTMLElement; if (messageEl) { messageEl.innerHTML = ''; } } // Restore center play button (with null check for disposed player) if (this.playerWrapper) { const centerPlayBtn = this.playerWrapper.querySelector('.uvf-center-play-btn') as HTMLElement; if (centerPlayBtn) { centerPlayBtn.style.display = ''; } } } /** * Update countdown configuration dynamically */ public updateLiveCountdown(config: any): void { // Handle undefined/null config - stop countdown if currently showing if (!config || !config.nextProgramStartTime) { this.debugLog('⏱️ updateLiveCountdown called with no config/timestamp - stopping countdown'); if (this.isShowingLiveCountdown) { this.stopLiveCountdown(); } this.config.liveCountdown = config; return; } // Check if this is a different timestamp (new countdown) const oldTimestamp = this.config.liveCountdown?.nextProgramStartTime; const newTimestamp = config.nextProgramStartTime; const isDifferentTimestamp = oldTimestamp !== newTimestamp; this.debugLog(`⏱️ updateLiveCountdown called: oldTimestamp=${oldTimestamp}, newTimestamp=${newTimestamp}, isDifferent=${isDifferentTimestamp}, hasCountdownCompleted=${this.hasCountdownCompleted}`); this.config.liveCountdown = config; // Only reset flags if setting a NEW countdown timestamp // Don't reset if it's the same timestamp (prevents re-showing after completion) if (isDifferentTimestamp) { this.debugLog(`⏱️ Different timestamp detected - resetting flags`); this.hasCountdownCompleted = false; this.countdownCallbackFired = false; // Reset callback flag for new countdown } else { this.debugLog(`⏱️ Same timestamp - keeping hasCountdownCompleted=${this.hasCountdownCompleted}`); } // Stop existing countdown if (this.isShowingLiveCountdown) { this.stopLiveCountdown(); } // Start new countdown if configured, not yet completed, and not currently reloading if (!this.hasCountdownCompleted && !this.isReloadingAfterCountdown) { const remainingMs = newTimestamp - Date.now(); if (remainingMs > 0) { this.showLiveCountdown(); } } } // ============================================ // Multi-Instance Helper Methods // ============================================ /** * Get a unique element ID for this player instance * @param baseName - The base element name (e.g., 'loading', 'progress-bar') * @returns Full element ID with instance prefix (e.g., 'uvf-player-1-loading') */ private getElementId(baseName: string): string { return `uvf-${this.instanceId}-${baseName}`; } /** * Get an element by its base name (scoped to this player instance) * @param baseName - The base element name (e.g., 'loading', 'progress-bar') * @returns The HTML element or null if not found */ private getElement(baseName: string): HTMLElement | null { return document.getElementById(this.getElementId(baseName)); } /** * Cache frequently-accessed element references for performance * Should be called after controls are created and appended to document */ private cacheElementReferences(): void { this.cachedElements = { loading: this.getElement('loading'), progressBar: this.getElement('progress-bar'), progressFilled: this.getElement('progress-filled'), progressHandle: this.getElement('progress-handle'), progressBuffered: this.getElement('progress-buffered'), playIcon: this.getElement('play-icon'), pauseIcon: this.getElement('pause-icon'), centerPlay: this.getElement('center-play'), timeDisplay: this.getElement('time-display'), thumbnailPreview: this.getElement('thumbnail-preview'), thumbnailImage: this.getElement('thumbnail-image') as HTMLImageElement, thumbnailTime: this.getElement('thumbnail-time'), timeTooltip: this.getElement('time-tooltip'), settingsMenu: this.getElement('settings-menu'), fullscreenBtn: this.getElement('fullscreen-btn'), }; } // ============================================ // Controls Visibility Methods // ============================================ /** * Deep merge partial controls visibility config with existing config */ private deepMergeControlsConfig( existing: ControlsVisibilityConfig, partial: Partial<ControlsVisibilityConfig> ): ControlsVisibilityConfig { return { playback: { ...existing.playback, ...partial.playback }, audio: { ...existing.audio, ...partial.audio }, progress: { ...existing.progress, ...partial.progress }, quality: { ...existing.quality, ...partial.quality }, display: { ...existing.display, ...partial.display }, features: { ...existing.features, ...partial.features }, chrome: { ...existing.chrome, ...partial.chrome }, }; } /** * Set element visibility by base name * @param elementBaseName - The base element name without instance prefix (e.g., 'center-play', not 'uvf-player-1-center-play') */ private setElementVisibility(elementBaseName: string, visible: boolean | undefined): void { if (visible === undefined) return; // No change const element = this.getElement(elementBaseName); if (element) { if (visible) { // Show: remove hidden class and clear inline style element.classList.remove('uvf-hidden'); element.style.display = ''; } else { // Hide: add hidden class AND set inline style for double protection element.classList.add('uvf-hidden'); element.style.setProperty('display', 'none', 'important'); } } } /** * Apply controlsVisibility configuration to DOM elements */ private applyControlsVisibility(): void { if (!this.container) return; const cv = this.controlsVisibility; // Playback controls this.setElementVisibility('center-play', cv.playback?.centerPlayButton); this.setElementVisibility('play-pause', cv.playback?.playPauseButton); this.setElementVisibility('skip-back', cv.playback?.skipButtons); this.setElementVisibility('skip-forward', cv.playback?.skipButtons); this.setElementVisibility('btn-prev', cv.playback?.previousButton); this.setElementVisibility('btn-next', cv.playback?.nextButton); // Audio controls const volumeControl = this.container.querySelector('.uvf-volume-control'); if (volumeControl) { (volumeControl as HTMLElement).style.display = cv.audio?.volumeButton ? '' : 'none'; } this.setElementVisibility('volume-panel', cv.audio?.volumeSlider); // Progress controls this.setElementVisibility('progress-bar', cv.progress?.progressBar); this.setElementVisibility('time-display', cv.progress?.timeDisplay); // Quality controls this.setElementVisibility('quality-badge', cv.quality?.badge); const settingsContainer = this.container.querySelector('.uvf-settings-container'); if (settingsContainer) { (settingsContainer as HTMLElement).style.display = cv.quality?.settingsButton ? '' : 'none'; } // Display controls this.setElementVisibility('fullscreen-btn', cv.display?.fullscreenButton); this.setElementVisibility('pip-btn', cv.display?.pipButton); // Feature controls this.setElementVisibility('epg-btn', cv.features?.epgButton); this.setElementVisibility('playlist-btn', cv.features?.playlistButton); this.setElementVisibility('cast-btn', cv.features?.castButton); // NOTE: Stop cast button visibility is controlled by _syncCastButtons() based on runtime casting state // It should only show when actively casting, not be user-configurable this.setElementVisibility('share-btn', cv.features?.shareButton); // Chrome controls const topBar = this.container.querySelector('.uvf-top-bar'); if (topBar) { (topBar as HTMLElement).style.display = cv.chrome?.navigationButtons ? '' : 'none'; } const brandingContainer = this.container.querySelector('.uvf-framework-branding'); if (brandingContainer) { (brandingContainer as HTMLElement).style.display = cv.chrome?.frameworkBranding ? '' : 'none'; } } /** * Enable or disable all custom player controls * @param enabled - true to show all controls, false to hide all controls * @throws Error if player is not initialized */ public setControlsEnabled(enabled: boolean): void { if (!this.container || !this.playerWrapper) { throw new Error('Player not initialized. Call initialize() first.'); } this.useCustomControls = enabled; if (enabled) { this.playerWrapper.classList.remove('controls-disabled'); this.playerWrapper.classList.add('controls-visible'); } else { this.playerWrapper.classList.add('controls-disabled'); this.playerWrapper.classList.remove('controls-visible'); } this.debugLog(`Controls ${enabled ? 'enabled' : 'disabled'}`); } /** * Update visibility of individual control elements * @param config - Partial controls visibility configuration (deep merged with existing) * @throws Error if player is not initialized */ public setControlsVisibility(config: Partial<ControlsVisibilityConfig>): void { if (!this.container || !this.playerWrapper) { throw new Error('Player not initialized. Call initialize() first.'); } // Deep merge partial config into existing controlsVisibility this.controlsVisibility = this.deepMergeControlsConfig(this.controlsVisibility, config); // Apply visibility changes to DOM elements this.applyControlsVisibility(); this.debugLog('Controls visibility updated:', this.controlsVisibility); } // ============================================ // Thumbnail Preview Methods // ============================================ /** * Initialize thumbnail preview with config */ public initializeThumbnailPreview(config: ThumbnailPreviewConfig): void { if (!config || !config.generationImage) { this.thumbnailPreviewEnabled = false; return; } this.thumbnailPreviewConfig = config; this.thumbnailPreviewEnabled = config.enabled !== false; // Transform generation image data to sorted array for efficient lookup this.thumbnailEntries = this.transformThumbnailData(config.generationImage); this.debugLog('Thumbnail preview initialized:', { enabled: this.thumbnailPreviewEnabled, entries: this.thumbnailEntries.length }); // Apply custom styles if provided if (config.style) { this.applyThumbnailStyles(config.style); } // Preload images if enabled (default: true) if (config.preloadImages !== false && this.thumbnailEntries.length > 0) { this.preloadThumbnailImages(); } } /** * Transform generation image JSON to sorted array of ThumbnailEntry */ private transformThumbnailData(generationImage: ThumbnailGenerationImage): ThumbnailEntry[] { const entries: ThumbnailEntry[] = []; for (const [url, timeRange] of Object.entries(generationImage)) { const parts = timeRange.split('-'); if (parts.length === 2) { const startTime = parseFloat(parts[0]); const endTime = parseFloat(parts[1]); if (!isNaN(startTime) && !isNaN(endTime)) { entries.push({ url, startTime, endTime }); } } } // Sort by startTime for efficient binary search entries.sort((a, b) => a.startTime - b.startTime); return entries; } /** * Find thumbnail for a given time using binary search (O(log n)) */ private findThumbnailForTime(time: number): ThumbnailEntry | null { if (this.thumbnailEntries.length === 0) return null; let left = 0; let right = this.thumbnailEntries.length - 1; let result: ThumbnailEntry | null = null; while (left <= right) { const mid = Math.floor((left + right) / 2); const entry = this.thumbnailEntries[mid]; if (time >= entry.startTime && time < entry.endTime) { return entry; } else if (time < entry.startTime) { right = mid - 1; } else { left = mid + 1; } } // If no exact match, find the closest entry if (left > 0 && left <= this.thumbnailEntries.length) { const prevEntry = this.thumbnailEntries[left - 1]; if (time >= prevEntry.startTime && time < prevEntry.endTime) { return prevEntry; } } return result; } /** * Preload all thumbnail images for instant switching */ private preloadThumbnailImages(): void { this.debugLog('Preloading', this.thumbnailEntries.length, 'thumbnail images'); for (const entry of this.thumbnailEntries) { if (this.preloadedThumbnails.has(entry.url)) continue; const img = new Image(); img.onload = () => { this.preloadedThumbnails.set(entry.url, img); this.debugLog('Preloaded thumbnail:', entry.url); }; img.onerror = () => { this.debugWarn('Failed to preload thumbnail:', entry.url); }; img.src = entry.url; } } /** * Apply custom thumbnail styles */ private applyThumbnailStyles(style: ThumbnailPreviewConfig['style']): void { if (!style) return; const wrapper = this.cachedElements.thumbnailPreview; const imageWrapper = wrapper?.querySelector('.uvf-thumbnail-preview-image-wrapper') as HTMLElement; if (imageWrapper) { if (style.width) { imageWrapper.style.width = `${style.width}px`; } if (style.height) { imageWrapper.style.height = `${style.height}px`; } if (style.borderRadius !== undefined) { imageWrapper.style.borderRadius = `${style.borderRadius}px`; } } } /** * Update thumbnail preview based on mouse position */ private updateThumbnailPreview(e: MouseEvent): void { if (!this.thumbnailPreviewEnabled || this.thumbnailEntries.length === 0) { return; } const progressBar = this.cachedElements.progressBar; const thumbnailPreview = this.cachedElements.thumbnailPreview; const thumbnailImage = this.cachedElements.thumbnailImage; const thumbnailTime = this.cachedElements.thumbnailTime; if (!progressBar || !thumbnailPreview || !thumbnailImage || !this.video) { return; } const rect = progressBar.getBoundingClientRect(); const x = e.clientX - rect.left; const percent = Math.max(0, Math.min(1, x / rect.width)); const time = percent * this.video.duration; // Find thumbnail for this time const entry = this.findThumbnailForTime(time); if (entry) { // Show thumbnail preview thumbnailPreview.classList.add('visible'); // Calculate position (clamp to prevent overflow) const thumbnailWidth = 160; // Default width const halfWidth = thumbnailWidth / 2; const minX = halfWidth; const maxX = rect.width - halfWidth; const clampedX = Math.max(minX, Math.min(maxX, x)); thumbnailPreview.style.left = `${clampedX}px`; // Update image only if URL changed if (this.currentThumbnailUrl !== entry.url) { this.currentThumbnailUrl = entry.url; thumbnailImage.classList.remove('loaded'); // Check if image is preloaded const preloaded = this.preloadedThumbnails.get(entry.url); if (preloaded) { thumbnailImage.src = preloaded.src; thumbnailImage.classList.add('loaded'); } else { thumbnailImage.onload = () => { thumbnailImage.classList.add('loaded'); }; thumbnailImage.src = entry.url; } } // Update time display if (thumbnailTime && this.thumbnailPreviewConfig?.showTimeInThumbnail !== false) { thumbnailTime.textContent = this.formatTime(time); thumbnailTime.style.display = 'block'; } else if (thumbnailTime) { thumbnailTime.style.display = 'none'; } } else { // No thumbnail for this time - hide preview and let regular tooltip show this.hideThumbnailPreview(); } } /** * Hide thumbnail preview */ private hideThumbnailPreview(): void { const thumbnailPreview = this.cachedElements.thumbnailPreview; if (thumbnailPreview) { thumbnailPreview.classList.remove('visible'); } this.currentThumbnailUrl = null; } /** * Set thumbnail preview config at runtime */ public setThumbnailPreview(config: ThumbnailPreviewConfig | null): void { if (!config) { this.thumbnailPreviewEnabled = false; this.thumbnailPreviewConfig = null; this.thumbnailEntries = []; this.preloadedThumbnails.clear(); this.hideThumbnailPreview(); return; } this.initializeThumbnailPreview(config); } // Note: Uses existing formatTime method defined elsewhere in this class // ============================================ // End Thumbnail Preview Methods // ============================================ async initialize(container: HTMLElement | string, config?: any): Promise<void> { // Generate unique instance ID for this player this.instanceId = `player-${++WebPlayer.instanceCounter}`; console.log(`[WebPlayer] Instance ${this.instanceId} initializing`); // Debug log the config being passed console.log('WebPlayer.initialize called with config:', config); // Set useCustomControls based on controls config if (config && config.controls !== undefined) { 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); } // Store config for later use (needed by resolveControlVisibility and other methods) this.config = config || {}; // Resolve control visibility with backward compatibility this.controlsVisibility = this.resolveControlVisibility(); console.log('Controls visibility resolved:', this.controlsVisibility); // 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; } else if (this.useCustomControls) { // Fallback: If custom controls are enabled and youtubeNativeControls is not explicitly set, // default to hiding native controls so our custom controls can show. // Note: WebPlayerView.tsx defaults youtubeNativeControls to false, so this is a safety fallback. console.log('Custom controls enabled, defaulting YouTube native controls to false'); this.youtubeNativeControls = false; } // Configure thumbnail preview if provided if (config && config.thumbnailPreview) { console.log('Thumbnail preview config found:', config.thumbnailPreview); this.initializeThumbnailPreview(config.thumbnailPreview); } // 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; // NEVER loop live streams - they should either keep playing or stop when ended this.video.loop = this.config.isLive ? false : (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; } // Create subtitle overlay this.subtitleOverlay = document.createElement('div'); this.subtitleOverlay.className = 'uvf-subtitle-overlay'; videoContainer.appendChild(this.subtitleOverlay); // 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); // Assemble the player - add video container first wrapper.appendChild(videoContainer); // Always create custom controls - append to wrapper (not videoContainer) // This ensures controls are not constrained by the video's aspect-ratio this.createCustomControls(wrapper); // Apply controls-disabled class if controls={false} if (!this.useCustomControls) { wrapper.classList.add('controls-disabled'); } else { // Make controls visible by default when controls={true} wrapper.classList.add('controls-visible'); } // Add to container this.container.innerHTML = ''; this.container.appendChild(wrapper); // NOW apply control visibility - elements are in the document and can be found by getElementById() this.applyControlsVisibility(); // Cache frequently-accessed element references for performance this.cacheElementReferences(); // Measure the controls bar height on the first rendered frame and expose it as // --uvf-ctrl-height so the subtitle overlay is positioned correctly right from // startup (controls-visible class is already added above, so without this the // subtitle would use the 0px fallback until the next showControls() call). requestAnimationFrame(() => { if (this.controlsContainer && this.playerWrapper) { this.playerWrapper.style.setProperty( '--uvf-ctrl-height', `${this.controlsContainer.getBoundingClientRect().height}px` ); } }); // 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) {