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
text/typescript
/**
* 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) {