unified-video-framework
Version:
Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more
999 lines (998 loc) • 78.9 kB
JavaScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { WebPlayer } from '../WebPlayer.js';
import { GoogleAdsManager } from '../ads/GoogleAdsManager.js';
import PlaylistPanel from './components/playlist/PlaylistPanel.js';
import { useCommerceSync } from './hooks/useCommerceSync.js';
import ProductBadge from './components/commerce/ProductBadge.js';
import ProductPanel from './components/commerce/ProductPanel.js';
let EPGOverlay = null;
const loadEPGComponents = async () => {
if (!EPGOverlay) {
try {
const epgModule = await import('./components/EPGOverlay.js');
EPGOverlay = epgModule.default;
}
catch (error) {
console.warn('Failed to load EPG components:', error);
}
}
return EPGOverlay;
};
export const WebPlayerView = (props) => {
const containerRef = useRef(null);
const fullscreenHostRef = useRef(null);
const internalPlayerRef = useRef(null);
const playerRef = props.playerRef || internalPlayerRef;
const [dimensions, setDimensions] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 1920,
height: typeof window !== 'undefined' ? window.innerHeight : 1080,
});
const [currentTime, setCurrentTime] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const [manifest, setManifest] = useState(props.commerce?.manifest);
const [panelOpen, setPanelOpen] = useState(false);
const [userClosedPanel, setUserClosedPanel] = useState(false);
const prevIsPausedRef = useRef(false);
const [playerWrapperElement, setPlayerWrapperElement] = useState(null);
const { activeOverlays, activeProducts } = useCommerceSync(manifest, currentTime, isPaused);
useEffect(() => {
if (!props.commerce?.manifestUrl)
return;
let cancelled = false;
(async () => {
try {
const r = await fetch(props.commerce.manifestUrl);
if (!r.ok)
return;
const json = await r.json();
if (!cancelled)
setManifest(json);
}
catch { }
})();
return () => { cancelled = true; };
}, [props.commerce?.manifestUrl]);
useEffect(() => {
if (!props.commerce?.autoOpenPanel)
return;
if (isPaused && activeProducts.length > 0 && !panelOpen && !userClosedPanel) {
console.log('[COMMERCE] Auto-opening panel on pause with active products');
setPanelOpen(true);
}
if (!isPaused && panelOpen && !userClosedPanel) {
console.log('[COMMERCE] Auto-closing panel when playing');
setPanelOpen(false);
}
if (prevIsPausedRef.current && !isPaused && userClosedPanel) {
console.log('[COMMERCE] Resetting userClosedPanel on play transition');
setUserClosedPanel(false);
}
prevIsPausedRef.current = isPaused;
}, [activeProducts.length, panelOpen, props.commerce?.autoOpenPanel, userClosedPanel, isPaused]);
const [epgVisible, setEPGVisible] = useState(props.showEPG || false);
const [playerReady, setPlayerReady] = useState(false);
const [epgComponentLoaded, setEPGComponentLoaded] = useState(false);
const [hasUsedEPG, setHasUsedEPG] = useState(() => {
try {
return localStorage.getItem('uvf-epg-used') === 'true';
}
catch {
return false;
}
});
useEffect(() => {
const p = playerRef.current;
if (!p || typeof p.on !== 'function')
return;
if (!props.commerce)
return;
const handleTime = (ct) => setCurrentTime(ct);
const handlePause = () => setIsPaused(true);
const handlePlay = () => setIsPaused(false);
p.on('onTimeUpdate', handleTime);
p.on('onPause', handlePause);
p.on('onPlay', handlePlay);
return () => {
if (typeof p.off === 'function') {
p.off('onTimeUpdate', handleTime);
p.off('onPause', handlePause);
p.off('onPlay', handlePlay);
}
};
}, [playerReady, props.commerce]);
const adsManagerRef = useRef(null);
const adContainerRef = useRef(null);
const [isAdPlaying, setIsAdPlaying] = useState(false);
const playlistItems = props.playlist?.items ?? [];
const [playlistIndex, setPlaylistIndex] = useState(props.playlist?.startIndex ?? 0);
const [playlistLoop, setPlaylistLoop] = useState(props.playlist?.loop ?? false);
const [playlistShuffle, setPlaylistShuffle] = useState(props.playlist?.shuffle ?? false);
const [shuffleOrder, setShuffleOrder] = useState([]);
const [playlistPanelVisible, setPlaylistPanelVisible] = useState(() => props.playlist?.defaultVisible ?? (playlistItems.length > 0));
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
document.addEventListener('webkitfullscreenchange', onFsChange);
return () => {
document.removeEventListener('fullscreenchange', onFsChange);
document.removeEventListener('webkitfullscreenchange', onFsChange);
};
}, []);
const playlistIndexRef = useRef(playlistIndex);
const playlistLoopRef = useRef(playlistLoop);
const playlistShuffleRef = useRef(playlistShuffle);
const shuffleOrderRef = useRef(shuffleOrder);
useEffect(() => { playlistIndexRef.current = playlistIndex; }, [playlistIndex]);
useEffect(() => { playlistLoopRef.current = playlistLoop; }, [playlistLoop]);
useEffect(() => { playlistShuffleRef.current = playlistShuffle; }, [playlistShuffle]);
useEffect(() => { shuffleOrderRef.current = shuffleOrder; }, [shuffleOrder]);
const buildShuffleOrder = (length, currentIdx) => {
const indices = Array.from({ length }, (_, i) => i).filter(i => i !== currentIdx);
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
return [currentIdx, ...indices];
};
const getNextIndex = (current, length, loop, shuffle, order) => {
if (length <= 1)
return null;
if (shuffle && order.length === length) {
const pos = order.indexOf(current);
if (pos < length - 1)
return order[pos + 1];
return loop ? order[0] : null;
}
if (current < length - 1)
return current + 1;
return loop ? 0 : null;
};
const getPrevIndex = (current, length, loop) => {
if (length <= 1)
return null;
if (current > 0)
return current - 1;
return loop ? length - 1 : null;
};
const loadPlaylistItem = useCallback((index) => {
const item = playlistItems[index];
if (!playerRef.current || !item)
return;
const p = playerRef.current;
p.load({
url: item.url,
type: item.type ?? 'auto',
metadata: { title: item.title, posterUrl: item.thumbnail },
subtitles: item.subtitles,
}).catch(() => { });
p.play().catch(() => { });
setPlaylistIndex(index);
playlistIndexRef.current = index;
props.playlist?.onItemChange?.(item, index);
}, [props.playlist, playlistItems]);
const loadPlaylistItemRef = useRef(loadPlaylistItem);
useEffect(() => { loadPlaylistItemRef.current = loadPlaylistItem; }, [loadPlaylistItem]);
const generateAdMarkers = useCallback((cuePoints, videoDuration) => {
if (!cuePoints || cuePoints.length === 0)
return [];
return cuePoints.map((time, index) => {
let actualTime = time;
let title = 'Ad Break';
if (time === 0) {
actualTime = 0;
title = 'Pre-roll Ad';
}
else if (time === -1) {
actualTime = videoDuration > 0 ? videoDuration - 0.1 : 0;
title = 'Post-roll Ad';
}
else {
title = `Mid-roll Ad ${index + 1}`;
}
return {
id: `ad-${index}-${time}`,
type: 'ad',
startTime: actualTime,
endTime: actualTime + 0.1,
title,
skipLabel: 'Skip Ad',
description: `Advertisement at ${formatAdTime(actualTime)}`,
autoSkip: false,
showSkipButton: false,
metadata: {
adBreakIndex: index,
originalTime: time,
isPreroll: time === 0,
isPostroll: time === -1,
source: 'google-ads',
},
};
});
}, []);
const formatAdTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const mergeAdMarkersWithChapters = useCallback((chapters, adMarkers, videoDuration) => {
if (adMarkers.length === 0)
return chapters;
const customStyles = chapters?.customStyles || {};
if (!chapters || !chapters.data) {
return {
enabled: true,
showChapterMarkers: true,
customStyles,
data: {
videoId: 'auto-generated',
duration: videoDuration,
segments: adMarkers,
},
};
}
const existingSegments = chapters.data?.segments || [];
const clampedExistingSegments = existingSegments.map((segment) => {
if (segment.endTime > videoDuration) {
console.warn(`[AdMarkers] Clamping segment "${segment.title || segment.id}" endTime from ${segment.endTime} to ${videoDuration}`);
return { ...segment, endTime: Math.min(segment.endTime, videoDuration) };
}
return segment;
});
const allSegments = [...clampedExistingSegments, ...adMarkers]
.sort((a, b) => a.startTime - b.startTime);
console.log('[AdMarkers] Merged chapters:', {
existingSegmentCount: clampedExistingSegments.length,
adMarkerCount: adMarkers.length,
totalSegmentCount: allSegments.length,
videoDuration,
finalDuration: videoDuration,
});
return {
...chapters,
enabled: true,
showChapterMarkers: true,
customStyles,
data: {
...chapters.data,
duration: videoDuration,
segments: allSegments,
},
};
}, []);
useEffect(() => {
if (typeof window === 'undefined')
return;
const responsiveEnabled = props.responsive?.enabled !== false;
if (!responsiveEnabled)
return;
const handleResize = () => {
setDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, [props.responsive?.enabled]);
const handleToggleEPG = useCallback((visible) => {
setEPGVisible(visible);
if (visible && !hasUsedEPG) {
try {
localStorage.setItem('uvf-epg-used', 'true');
setHasUsedEPG(true);
}
catch (e) {
}
}
if (props.onToggleEPG) {
props.onToggleEPG(visible);
}
}, [props.onToggleEPG, hasUsedEPG]);
useEffect(() => {
if (props.epg && !epgComponentLoaded) {
console.log('🔄 Loading EPG components...');
loadEPGComponents().then((component) => {
if (component) {
console.log('✅ Setting epgComponentLoaded to true');
setEPGComponentLoaded(true);
}
}).catch((error) => {
console.error('Failed to load EPG components:', error);
});
}
}, [props.epg, epgComponentLoaded]);
useEffect(() => {
if (props.showEPG !== undefined) {
setEPGVisible(props.showEPG);
}
}, [props.showEPG]);
useEffect(() => {
if (!props.epg)
return;
const handleKeyPress = (e) => {
if ((e.key === 'g' || e.key === 'G') && e.ctrlKey) {
e.preventDefault();
handleToggleEPG(!epgVisible);
}
};
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, [props.epg, epgVisible, handleToggleEPG]);
useEffect(() => {
if (playerRef.current && props.liveCountdown) {
try {
playerRef.current.updateLiveCountdown(props.liveCountdown);
}
catch (error) {
console.warn('Failed to update live countdown:', error);
}
}
return () => {
if (playerRef.current) {
try {
const player = playerRef.current;
if (player.liveCountdownTimer) {
clearInterval(player.liveCountdownTimer);
player.liveCountdownTimer = null;
}
}
catch (error) {
console.warn('Failed to cleanup countdown timer:', error);
}
}
};
}, [props.liveCountdown]);
useEffect(() => {
return () => {
if (playerRef.current && playerRef.current.isShowingLiveCountdown) {
try {
playerRef.current.stopLiveCountdown();
}
catch (error) {
console.warn('Failed to stop live countdown on unmount:', error);
}
}
};
}, []);
const getResponsiveDimensions = () => {
const responsiveEnabled = props.responsive?.enabled !== false;
if (!responsiveEnabled)
return props.style || {};
const { width, height } = dimensions;
const responsive = props.responsive || {};
const defaults = {
aspectRatio: responsive.aspectRatio || 16 / 9,
maxWidth: responsive.maxWidth || '100vw',
maxHeight: responsive.maxHeight || '100vh',
breakpoints: {
mobile: responsive.breakpoints?.mobile || 768,
tablet: responsive.breakpoints?.tablet || 1024,
},
mobilePortrait: {
maxHeight: responsive.mobilePortrait?.maxHeight || '100vh',
aspectRatio: responsive.mobilePortrait?.aspectRatio,
},
mobileLandscape: {
maxHeight: responsive.mobileLandscape?.maxHeight || '100vh',
aspectRatio: responsive.mobileLandscape?.aspectRatio,
},
tablet: {
maxWidth: responsive.tablet?.maxWidth || '100vw',
maxHeight: responsive.tablet?.maxHeight || '100vh',
},
};
const isMobile = width < defaults.breakpoints.mobile;
const isTablet = width >= defaults.breakpoints.mobile && width < defaults.breakpoints.tablet;
const isPortrait = height > width;
const isLandscape = width > height;
const playerHeight = '100vh';
const playerMaxHeight = '100vh';
const playerZIndex = props.epg ? 50 : 1000;
let calculatedStyle = {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
zIndex: playerZIndex,
backgroundColor: '#000000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: 0,
padding: 0,
transition: 'none',
...props.style,
};
if (isMobile && isPortrait) {
calculatedStyle = {
...calculatedStyle,
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
position: 'absolute',
top: 0,
left: 0,
};
}
else if (isMobile && isLandscape) {
calculatedStyle = {
...calculatedStyle,
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
position: 'absolute',
top: 0,
left: 0,
};
}
else if (isTablet) {
calculatedStyle = {
...calculatedStyle,
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
position: 'absolute',
top: 0,
left: 0,
};
}
else {
calculatedStyle = {
...calculatedStyle,
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
position: 'absolute',
top: 0,
left: 0,
};
}
return calculatedStyle;
};
useEffect(() => {
try {
const params = new URLSearchParams(window.location.search);
const popup = (params.get('popup') || '').toLowerCase() === '1';
const status = (params.get('rental') || '').toLowerCase();
const orderId = params.get('order_id') || '';
const sessionId = params.get('session_id') || '';
if (popup && (status === 'success' || status === 'cancel')) {
try {
window.opener?.postMessage({ type: 'uvfCheckout', status, orderId, sessionId }, '*');
}
catch (_) { }
try {
window.close();
}
catch (_) { }
}
}
catch (_) { }
}, []);
const hasBootedRef = useRef(false);
useEffect(() => {
console.log('[WebPlayerView] 🎬 Boot useEffect triggered');
let cancelled = false;
async function boot() {
console.log('[WebPlayerView] 🚀 boot() called, hasBooted:', hasBootedRef.current);
if (!containerRef.current) {
console.log('[WebPlayerView] ❌ boot() aborted: no container');
return;
}
if (hasBootedRef.current) {
console.log('[WebPlayerView] ⛔ Boot already executed, skipping duplicate initialization');
return;
}
hasBootedRef.current = true;
console.log('[WebPlayerView] ✅ Proceeding with player initialization');
const player = new WebPlayer();
playerRef.current = player;
exposeAPIsToParent(player);
if (props.cast) {
try {
const existing = document.querySelector('script[data-cast-sdk="1"]');
if (!existing) {
const s = document.createElement('script');
s.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
s.async = true;
s.setAttribute('data-cast-sdk', '1');
document.head.appendChild(s);
}
}
catch (_) {
}
}
let paywallCfg = props.paywall;
if (!paywallCfg && props.paywallConfigUrl) {
try {
const resp = await fetch(props.paywallConfigUrl);
if (resp.ok)
paywallCfg = await resp.json();
}
catch (_) { }
}
if (props.emailAuth?.enabled) {
if (!paywallCfg) {
paywallCfg = {
enabled: true,
apiBase: 'http://localhost:3000',
userId: 'user-' + Math.random().toString(36).substr(2, 9),
videoId: 'video-' + Math.random().toString(36).substr(2, 9),
gateways: ['stripe'],
};
}
paywallCfg = {
...paywallCfg,
emailAuth: {
enabled: props.emailAuth.enabled,
skipIfAuthenticated: props.emailAuth.skipIfAuthenticated ?? true,
sessionStorage: {
tokenKey: props.emailAuth.sessionStorage?.tokenKey || 'uvf_session_token',
refreshTokenKey: props.emailAuth.sessionStorage?.refreshTokenKey || 'uvf_refresh_token',
userIdKey: props.emailAuth.sessionStorage?.userIdKey || 'uvf_user_id',
},
api: {
requestOtp: props.emailAuth.apiEndpoints?.requestOtp || '/auth/request-otp',
verifyOtp: props.emailAuth.apiEndpoints?.verifyOtp || '/auth/verify-otp',
refreshToken: props.emailAuth.apiEndpoints?.refreshToken || '/auth/refresh-token',
logout: props.emailAuth.apiEndpoints?.logout || '/auth/logout',
},
ui: {
title: props.emailAuth.ui?.title || 'Sign in to continue',
description: props.emailAuth.ui?.description || 'Enter your email to receive a verification code',
emailPlaceholder: props.emailAuth.ui?.emailPlaceholder || 'Enter your email',
otpPlaceholder: props.emailAuth.ui?.otpPlaceholder || 'Enter 6-digit code',
submitButtonText: props.emailAuth.ui?.submitButtonText || 'Send Code',
resendButtonText: props.emailAuth.ui?.resendButtonText || 'Resend Code',
resendCooldown: props.emailAuth.ui?.resendCooldown || 30,
},
validation: {
otpLength: props.emailAuth.validation?.otpLength || 6,
otpTimeout: props.emailAuth.validation?.otpTimeout || 300,
rateLimiting: {
maxAttempts: props.emailAuth.validation?.rateLimiting?.maxAttempts || 5,
windowMinutes: props.emailAuth.validation?.rateLimiting?.windowMinutes || 60,
},
},
},
};
}
let watermarkConfig;
if (typeof props.watermark === 'boolean') {
watermarkConfig = { enabled: props.watermark };
}
else {
watermarkConfig = props.watermark;
}
let chaptersConfig = props.chapters;
if (props.googleAds && !chaptersConfig) {
chaptersConfig = {
enabled: true,
showChapterMarkers: true,
};
}
const config = {
autoPlay: props.autoPlay ?? false,
muted: props.muted ?? false,
volume: props.volume ?? 1.0,
controls: props.controls ?? true,
loop: props.loop ?? false,
preload: props.preload ?? 'metadata',
crossOrigin: props.crossOrigin,
playsInline: props.playsInline ?? true,
defaultQuality: props.defaultQuality,
enableAdaptiveBitrate: props.enableAdaptiveBitrate ?? true,
adaptiveBitrate: props.adaptiveBitrate,
debug: props.debug ?? false,
startTime: props.startTime,
freeDuration: props.freeDuration,
isLive: props.isLive,
liveWaitingMessages: props.liveWaitingMessages,
liveMessageRotationInterval: props.liveMessageRotationInterval,
liveRetryInterval: props.liveRetryInterval,
liveMaxRetryAttempts: props.liveMaxRetryAttempts,
liveBufferingTimeout: props.liveBufferingTimeout,
liveCountdown: props.liveCountdown,
paywall: paywallCfg,
youtubeNativeControls: props.youtubeNativeControls ?? false,
controlsVisibility: props.controlsVisibility,
settings: props.settings,
showFrameworkBranding: props.showFrameworkBranding,
watermark: watermarkConfig,
share: props.share,
qualityFilter: props.qualityFilter,
premiumQualities: props.premiumQualities,
navigation: props.navigation,
chapters: chaptersConfig ? {
enabled: chaptersConfig.enabled ?? (props.googleAds ? true : false),
data: chaptersConfig.data,
dataUrl: chaptersConfig.dataUrl,
autoHide: chaptersConfig.autoHide ?? true,
autoHideDelay: chaptersConfig.autoHideDelay ?? 5000,
showChapterMarkers: chaptersConfig.showChapterMarkers ?? true,
skipButtonPosition: chaptersConfig.skipButtonPosition ?? 'bottom-right',
customStyles: chaptersConfig.customStyles,
userPreferences: {
autoSkipIntro: chaptersConfig.userPreferences?.autoSkipIntro ?? false,
autoSkipRecap: chaptersConfig.userPreferences?.autoSkipRecap ?? false,
autoSkipCredits: chaptersConfig.userPreferences?.autoSkipCredits ?? false,
showSkipButtons: chaptersConfig.userPreferences?.showSkipButtons ?? true,
skipButtonTimeout: chaptersConfig.userPreferences?.skipButtonTimeout ?? 5000,
rememberChoices: chaptersConfig.userPreferences?.rememberChoices ?? true,
resumePlaybackAfterSkip: chaptersConfig.userPreferences?.resumePlaybackAfterSkip ?? true,
}
} : { enabled: false },
thumbnailPreview: props.thumbnailPreview
};
if (playlistItems.length > 0) {
config.playlist = {
showPrevNext: false,
replaceSkipWithPrevNext: true,
onPreviousTrack: () => {
const prev = getPrevIndex(playlistIndexRef.current, playlistItems.length, playlistLoopRef.current);
if (prev !== null)
loadPlaylistItemRef.current?.(prev);
},
onNextTrack: () => {
const next = getNextIndex(playlistIndexRef.current, playlistItems.length, playlistLoopRef.current, playlistShuffleRef.current, shuffleOrderRef.current);
if (next !== null)
loadPlaylistItemRef.current?.(next);
},
onPlaylistToggle: () => setPlaylistPanelVisible(v => !v),
};
}
if (playlistItems.length > 0 || props.epg) {
config.fullscreenElement = fullscreenHostRef.current ?? undefined;
}
try {
await player.initialize(containerRef.current, config);
const wrapper = containerRef.current?.querySelector('.uvf-player-wrapper');
if (wrapper) {
setPlayerWrapperElement(wrapper);
}
try {
if (props.playerTheme && player.setTheme) {
player.setTheme(props.playerTheme);
}
}
catch (_) { }
const source = {
url: props.url,
type: props.type ?? 'auto',
subtitles: props.subtitles,
metadata: props.metadata,
fallbackSources: props.fallbackSources,
fallbackPoster: props.fallbackPoster,
fallbackShowErrorMessage: props.fallbackShowErrorMessage,
fallbackRetryDelay: props.fallbackRetryDelay,
fallbackRetryAttempts: props.fallbackRetryAttempts,
onAllSourcesFailed: props.onAllSourcesFailed,
};
const shouldDelayVideoForAds = !!props.googleAds?.adTagUrl;
if (!shouldDelayVideoForAds) {
await player.load(source);
}
else {
console.log('🎬 Ads configured - delaying video load until ads initialize');
player.__pendingSource = source;
}
if (!cancelled) {
setPlayerReady(true);
if (props.qualityFilter && typeof player.setQualityFilter === 'function') {
player.setQualityFilter(props.qualityFilter);
}
if (props.settingsScrollbar) {
applySettingsScrollbar(player, props.settingsScrollbar);
}
if (props.autoFocusPlayer && typeof player.focusPlayer === 'function') {
player.focusPlayer();
}
if (props.showFullscreenTipOnMount && typeof player.showFullscreenTip === 'function') {
player.showFullscreenTip();
}
const injectAdMarkersFromTimes = (adTimes) => {
if (!adTimes || adTimes.length === 0)
return;
const duration = player.getDuration?.() || 0;
if (duration > 0) {
try {
const adMarkers = generateAdMarkers(adTimes, duration);
const mergedChapters = mergeAdMarkersWithChapters(props.chapters, adMarkers, duration);
if (typeof player.loadChapters === 'function' && mergedChapters.data) {
const invalidSegments = mergedChapters.data.segments.filter((s) => s.endTime > mergedChapters.data.duration);
if (invalidSegments.length > 0) {
console.warn('[AdMarkers] Found invalid segments that extend beyond duration:', invalidSegments);
}
player.loadChapters(mergedChapters.data);
console.log('✅ Ad markers injected:', adMarkers.length, 'markers at times:', adTimes);
}
}
catch (error) {
console.error('[AdMarkers] Failed to inject ad markers:', error);
props.googleAds?.onAdError?.(error);
}
}
else {
console.log('⏳ Duration not available yet, waiting for metadata to inject ad markers...');
const videoElement = player.video || player.getVideoElement?.();
if (videoElement) {
const retryInject = () => {
try {
const retryDuration = player.getDuration?.() || 0;
if (retryDuration > 0) {
const adMarkers = generateAdMarkers(adTimes, retryDuration);
const mergedChapters = mergeAdMarkersWithChapters(props.chapters, adMarkers, retryDuration);
if (typeof player.loadChapters === 'function' && mergedChapters.data) {
player.loadChapters(mergedChapters.data);
console.log('✅ Ad markers injected (after metadata):', adMarkers.length, 'markers at times:', adTimes);
}
}
else {
console.warn('⚠️ Duration still not available after metadata loaded');
}
}
catch (error) {
console.error('[AdMarkers] Failed to inject ad markers (retry):', error);
props.googleAds?.onAdError?.(error);
}
};
videoElement.addEventListener('loadedmetadata', retryInject, { once: true });
}
}
};
if (props.googleAds) {
const videoDuration = player.getDuration?.() || 0;
if (props.googleAds.midrollTimes && props.googleAds.midrollTimes.length > 0) {
const injectAdMarkers = () => {
injectAdMarkersFromTimes(props.googleAds.midrollTimes);
};
if (videoDuration > 0) {
injectAdMarkers();
}
else {
const videoElement = player.video || player.getVideoElement?.();
if (videoElement) {
videoElement.addEventListener('loadedmetadata', injectAdMarkers, { once: true });
}
}
}
}
if (props.epg && typeof player.setEPGData === 'function') {
player.setEPGData(props.epg);
}
if (typeof player.on === 'function') {
player.on('epgToggle', () => {
handleToggleEPG(!epgVisible);
});
}
if (props.onChapterChange && typeof player.on === 'function') {
player.on('chapterchange', props.onChapterChange);
}
if (props.onSegmentEntered && typeof player.on === 'function') {
player.on('segmententered', props.onSegmentEntered);
}
if (props.onSegmentExited && typeof player.on === 'function') {
player.on('segmentexited', props.onSegmentExited);
}
if (props.onSegmentSkipped && typeof player.on === 'function') {
player.on('segmentskipped', props.onSegmentSkipped);
}
if (props.onChapterSegmentEntered && typeof player.on === 'function') {
player.on('chapterSegmentEntered', props.onChapterSegmentEntered);
}
if (props.onChapterSegmentSkipped && typeof player.on === 'function') {
player.on('chapterSegmentSkipped', props.onChapterSegmentSkipped);
}
if (props.onChapterSkipButtonShown && typeof player.on === 'function') {
player.on('chapterSkipButtonShown', props.onChapterSkipButtonShown);
}
if (props.onChapterSkipButtonHidden && typeof player.on === 'function') {
player.on('chapterSkipButtonHidden', props.onChapterSkipButtonHidden);
}
if (props.onChaptersLoaded && typeof player.on === 'function') {
player.on('chaptersLoaded', props.onChaptersLoaded);
}
if (props.onChaptersLoadError && typeof player.on === 'function') {
player.on('chaptersLoadError', props.onChaptersLoadError);
}
if (props.onNavigationBackClicked && typeof player.on === 'function') {
player.on('navigationBackClicked', props.onNavigationBackClicked);
}
if (props.onNavigationCloseClicked && typeof player.on === 'function') {
player.on('navigationCloseClicked', props.onNavigationCloseClicked);
}
if (props.onPlay && typeof player.on === 'function') {
player.on('onPlay', props.onPlay);
}
if (props.onPause && typeof player.on === 'function') {
player.on('onPause', props.onPause);
}
if (props.onEnded && typeof player.on === 'function') {
player.on('onEnded', props.onEnded);
}
if (playlistItems.length > 1 && typeof player.on === 'function') {
player.on('onEnded', () => {
if (props.playlist?.autoAdvance === false)
return;
const next = getNextIndex(playlistIndexRef.current, playlistItems.length, playlistLoopRef.current, playlistShuffleRef.current, shuffleOrderRef.current);
if (next !== null)
loadPlaylistItemRef.current?.(next);
});
}
if (props.onTimeUpdate && typeof player.on === 'function') {
player.on('onTimeUpdate', (currentTime) => {
props.onTimeUpdate?.({
currentTime,
duration: player.getDuration ? player.getDuration() : 0
});
});
}
if (props.onProgress && typeof player.on === 'function') {
player.on('onProgress', (buffered) => {
props.onProgress?.({ buffered });
});
}
if (props.onVolumeChange && typeof player.on === 'function') {
player.on('onVolumeChanged', (volume) => {
const state = player.getState ? player.getState() : {};
props.onVolumeChange?.({ volume, muted: state.isMuted || false });
});
}
if (props.onQualityChange && typeof player.on === 'function') {
player.on('onQualityChanged', props.onQualityChange);
}
if (props.onBuffering && typeof player.on === 'function') {
player.on('onBuffering', props.onBuffering);
}
if (typeof player.on === 'function') {
player.on('onFullscreenChanged', (isFullscreen) => {
console.log(`🔄 Fullscreen changed: ${isFullscreen}`);
setIsFullscreen(isFullscreen);
if (adContainerRef.current) {
const adContainer = adContainerRef.current;
if (isFullscreen) {
adContainer.style.position = 'fixed';
adContainer.style.top = '0';
adContainer.style.left = '0';
adContainer.style.width = '100vw';
adContainer.style.height = '100vh';
adContainer.style.zIndex = '2147483647';
console.log('✅ Ad container: fullscreen positioning applied');
}
else {
adContainer.style.position = 'absolute';
adContainer.style.top = '0';
adContainer.style.left = '0';
adContainer.style.right = '0';
adContainer.style.bottom = '0';
adContainer.style.width = '';
adContainer.style.height = '';
adContainer.style.zIndex = '999999999';
console.log('✅ Ad container: normal positioning restored');
}
}
if (adsManagerRef.current) {
const google = window.google;
if (google && google.ima) {
const width = isFullscreen
? window.screen.width
: (containerRef.current?.clientWidth || window.innerWidth);
const height = isFullscreen
? window.screen.height
: (containerRef.current?.clientHeight || window.innerHeight);
const viewMode = isFullscreen
? google.ima.ViewMode.FULLSCREEN
: google.ima.ViewMode.NORMAL;
adsManagerRef.current.resize(width, height, viewMode);
console.log(`✅ Ads resized: ${width}x${height}, ViewMode: ${viewMode === google.ima.ViewMode.FULLSCREEN ? 'FULLSCREEN' : 'NORMAL'}`);
}
}
props.onFullscreenChange?.(isFullscreen);
});
}
if (props.onPictureInPictureChange && typeof player.on === 'function') {
player.on('onPictureInPicturechange', props.onPictureInPictureChange);
}
if (props.onLiveStreamWaiting && typeof player.on === 'function') {
player.on('onLiveStreamWaiting', props.onLiveStreamWaiting);
}
if (props.onLiveStreamReady && typeof player.on === 'function') {
player.on('onLiveStreamReady', props.onLiveStreamReady);
}
if (props.onLiveStreamUnavailable && typeof player.on === 'function') {
player.on('onLiveStreamUnavailable', props.onLiveStreamUnavailable);
}
if (props.onBandwidthDetected && typeof player.on === 'function') {
player.on('onBandwidthDetected', props.onBandwidthDetected);
}
if (props.onBandwidthTierChanged && typeof player.on === 'function') {
player.on('onBandwidthTierChanged', props.onBandwidthTierChanged);
}
if (props.googleAds) {
setTimeout(async () => {
try {
if (!adContainerRef.current) {
console.error('❌ Ad container ref is null - cannot initialize ads');
return;
}
const adContainer = adContainerRef.current;
const videoElement = player.video || player.getVideoElement?.();
if (!adContainer) {
console.error('❌ Ad container element not found');
return;
}
if (!videoElement) {
console.error('❌ Video element not found');
return;
}
console.log('✅ Initializing Google Ads...', {
adContainer: adContainer.className,
videoElement: videoElement.tagName,
adContainerInDOM: document.body.contains(adContainer)
});
let isPrerollPlaying = false;
let hasPrerollAd = false;
let waitingForAdResponse = true;
const blockVideoUntilPreroll = (e) => {
if (waitingForAdResponse || (isPrerollPlaying && hasPrerollAd)) {
console.log('⛔ Blocking video playback - waiting for ads');
e.preventDefault();
videoElement.pause();
return false;
}
return true;
};
console.log('🔒 Installing early video blocker - video cannot play until ads load');
videoElement.addEventListener('play', blockVideoUntilPreroll);
if (!videoElement.paused) {
console.log('⏸️ Pausing video - waiting for ad system to initialize');
videoElement.pause();
}
const adsManager = new GoogleAdsManager(videoElement, adContainer, {
adTagUrl: props.googleAds.adTagUrl,
midrollTimes: props.googleAds.midrollTimes,
companionAdSlots: props.googleAds.companionAdSlots,
liveAdBreakMode: props.googleAds.liveAdBreakMode,
periodicAdInterval: props.googleAds.periodicAdInterval,
syncToLiveEdge: props.googleAds.syncToLiveEdge,
pauseStreamDuringAd: props.googleAds.pauseStreamDuringAd,
liveEdgeOffset: props.googleAds.liveEdgeOffset,
onAdStart: () => {
setIsAdPlaying(true);
waitingForAdResponse = false;
if (videoElement.currentTime < 1) {
console.log('🎬 Pre-roll ad starting - blocking video playback');
isPrerollPlaying = true;
hasPrerollAd = true;
}
else {
videoElement.removeEventListener('play', blockVideoUntilPreroll);
}
if (typeof player.setAdPlaying === 'function') {
player.setAdPlaying(true);
}
props.googleAds?.onAdStart?.();
},
onAdEnd: () => {
setIsAdPlaying(false);
if (isPrerollPlaying) {
console.log('🎬 Pre-roll ad completed');
isPrerollPlaying = false;
videoElement.removeEventListener('play', blockVideoUntilPreroll);
if (videoElement.currentTime > 0 && videoElement.currentTime < 10) {
console.log(`⏮️ Resetting video to 0:00 (was at ${videoElement.currentTime.toFixed(2)}s)`);
videoElement.currentTime = 0;
}
}
if (typeof player.setAdPlaying === 'function') {
player.setAdPlaying(false);