UNPKG

unified-video-framework

Version:

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

669 lines (576 loc) 19.9 kB
/** * Google IMA Ads Manager * Supports ALL Google ad types: * - Pre-roll (before video) * - Mid-roll (during video) * - Post-roll (after video) * - Overlay ads (non-linear) * - Companion ads (sidebar/banner) * - Bumper ads (short 6s ads) * - Skippable & non-skippable ads */ export interface GoogleAdsConfig { // Ad tag URL (VAST/VMAP) adTagUrl: string; // Optional: Specific ad break times (for mid-rolls) // If not provided, uses VMAP schedule from ad server midrollTimes?: number[]; // e.g., [30, 60, 120] = ads at 30s, 60s, 120s // Companion ad containers companionAdSlots?: Array<{ containerId: string; // HTML element ID width: number; height: number; }>; // Callbacks onAdStart?: () => void; onAdEnd?: () => void; onAdError?: (error: any) => void; onAllAdsComplete?: () => void; onAdCuePoints?: (cuePoints: number[]) => void; // Called when ad schedule is loaded } export class GoogleAdsManager { private video: HTMLVideoElement; private adContainer: HTMLElement; private config: GoogleAdsConfig; private adsManager: any = null; private adsLoader: any = null; private adDisplayContainer: any = null; private isAdPlaying = false; private isMuted = true; private unmuteButton: HTMLElement | null = null; constructor(video: HTMLVideoElement, adContainer: HTMLElement, config: GoogleAdsConfig) { this.video = video; this.adContainer = adContainer; this.config = config; // Add focus handler to resume ads after click-through this.setupFocusHandler(); } /** * Setup focus handler to resume ads when window regains focus */ private setupFocusHandler(): void { window.addEventListener('focus', () => { if (this.isAdPlaying) { console.log('Window focused - resuming ad playback'); // Immediately resume this.resumeAdPlayback(); // Also try after a small delay as fallback setTimeout(() => this.resumeAdPlayback(), 200); } }); // Also check visibility change (tab switch) document.addEventListener('visibilitychange', () => { if (!document.hidden && this.isAdPlaying) { console.log('Tab became visible - resuming ad playback'); this.resumeAdPlayback(); } }); } /** * Initialize ads system */ async initialize(): Promise<void> { try { await this.loadIMASDK(); this.setupAdsLoader(); } catch (error) { console.error('Failed to initialize Google Ads:', error); this.config.onAdError?.(error); } } /** * Load Google IMA SDK */ private loadIMASDK(): Promise<void> { return new Promise((resolve, reject) => { if ((window as any).google?.ima) { resolve(); return; } const script = document.createElement('script'); script.src = 'https://imasdk.googleapis.com/js/sdkloader/ima3.js'; script.async = true; script.onload = () => resolve(); script.onerror = () => reject(new Error('Failed to load Google IMA SDK')); document.head.appendChild(script); }); } /** * Setup ads loader */ private setupAdsLoader(): void { const google = (window as any).google; // Create ad display container this.adDisplayContainer = new google.ima.AdDisplayContainer( this.adContainer, this.video ); // Create ads loader this.adsLoader = new google.ima.AdsLoader(this.adDisplayContainer); // Register for ads loaded event this.adsLoader.addEventListener( google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, (event: any) => this.onAdsManagerLoaded(event), false ); // Register for error event this.adsLoader.addEventListener( google.ima.AdErrorEvent.Type.AD_ERROR, (event: any) => this.onAdError(event), false ); // Signal when video content completes (for post-rolls) this.video.addEventListener('ended', () => { if (!this.isAdPlaying) { this.adsLoader?.contentComplete(); } }); } /** * Request ads */ requestAds(): void { const google = (window as any).google; try { const adsRequest = new google.ima.AdsRequest(); adsRequest.adTagUrl = this.config.adTagUrl; // Set video dimensions adsRequest.linearAdSlotWidth = this.video.clientWidth; adsRequest.linearAdSlotHeight = this.video.clientHeight; adsRequest.nonLinearAdSlotWidth = this.video.clientWidth; adsRequest.nonLinearAdSlotHeight = Math.floor(this.video.clientHeight / 3); // Set companion ad slots if provided if (this.config.companionAdSlots && this.config.companionAdSlots.length > 0) { const companionAdSlots = this.config.companionAdSlots.map(slot => { return new google.ima.CompanionAdSelectionSettings(); }); } // Chrome autoplay policy: ads must start muted for autoplay to work // User can unmute after ad starts adsRequest.setAdWillAutoPlay(true); adsRequest.setAdWillPlayMuted(true); // Start muted for Chrome compatibility // Request ads this.adsLoader.requestAds(adsRequest); } catch (error) { console.error('Error requesting ads:', error); this.config.onAdError?.(error); } } /** * Initialize ad display container (must be called on user gesture) */ initAdDisplayContainer(): void { try { this.adDisplayContainer?.initialize(); } catch (error) { console.warn('Ad display container already initialized'); } } /** * Handle ads manager loaded */ private onAdsManagerLoaded(event: any): void { const google = (window as any).google; // Setup ads rendering settings const adsRenderingSettings = new google.ima.AdsRenderingSettings(); adsRenderingSettings.restoreCustomPlaybackStateOnAdBreakComplete = true; adsRenderingSettings.enablePreloading = true; // Start muted for Chrome autoplay compatibility - user can unmute via button adsRenderingSettings.mute = this.video.muted; // Get the ads manager this.adsManager = event.getAdsManager(this.video, adsRenderingSettings); // Extract cue points (ad break times) from VMAP/ad server try { const cuePoints = this.adsManager.getCuePoints(); if (cuePoints && cuePoints.length > 0) { // Process all cue points including pre-roll (0) and post-roll (-1) const allCuePoints = cuePoints.map((time: number) => { // Convert special values to actual times if (time === 0) return 0; // Pre-roll at start if (time === -1) return -1; // Post-roll (will be converted to video duration later) return time; // Mid-roll at specific time }); console.log('📍 Ad cue points detected (pre/mid/post):', allCuePoints); // Notify callback with all cue points if (this.config.onAdCuePoints) { this.config.onAdCuePoints(allCuePoints); } } } catch (error) { console.warn('Could not extract ad cue points:', error); } // Setup ads manager event listeners this.setupAdsManagerListeners(); try { // Initialize ads manager this.adsManager.init( this.video.clientWidth, this.video.clientHeight, google.ima.ViewMode.NORMAL ); // Start ads this.adsManager.start(); } catch (error) { console.error('Error starting ads:', error); this.video.play().catch(() => { }); } } /** * Setup ads manager event listeners */ private setupAdsManagerListeners(): void { const google = (window as any).google; // Content pause - ad is about to play this.adsManager.addEventListener( google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED, () => { console.log('Ad: Content paused'); this.isAdPlaying = true; this.video.pause(); // Force ad container visibility and z-index if (this.adContainer) { this.adContainer.style.visibility = 'visible'; this.adContainer.style.opacity = '1'; this.adContainer.style.pointerEvents = 'auto'; this.adContainer.style.zIndex = '2147483647'; console.log('✅ Ad container visibility enforced'); } // Strict enforcement: Prevent video from playing during ads const preventPlayDuringAd = (e: Event) => { if (this.isAdPlaying) { e.preventDefault(); this.video.pause(); console.warn('Blocked video play attempt during ad'); } }; // Add play event listener to block any play attempts this.video.addEventListener('play', preventPlayDuringAd); // Store cleanup function to remove listener later (this.video as any).__adPlayBlocker = preventPlayDuringAd; this.config.onAdStart?.(); } ); // Content resume - ad finished this.adsManager.addEventListener( google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED, () => { console.log('Ad: Content resume'); this.isAdPlaying = false; // Remove play blocker const preventPlayDuringAd = (this.video as any).__adPlayBlocker; if (preventPlayDuringAd) { this.video.removeEventListener('play', preventPlayDuringAd); delete (this.video as any).__adPlayBlocker; } this.config.onAdEnd?.(); this.video.play().catch(() => { }); } ); // Ad started this.adsManager.addEventListener( google.ima.AdEvent.Type.STARTED, (event: any) => { const ad = event.getAd(); console.log('Ad started:', { type: ad.isLinear() ? 'Linear (video)' : 'Non-linear (overlay)', duration: ad.getDuration(), skippable: ad.getSkipTimeOffset() !== -1, title: ad.getTitle(), }); // Sync ads mute state with video element this.isMuted = this.video.muted; console.log(`Ad started - video.muted=${this.video.muted}, isMuted=${this.isMuted}`); // Check if video is actually muted and show unmute button only then if (this.isMuted) { this.showUnmuteButton(); } } ); // Ad completed this.adsManager.addEventListener( google.ima.AdEvent.Type.COMPLETE, () => { console.log('Ad completed'); } ); // All ads completed this.adsManager.addEventListener( google.ima.AdEvent.Type.ALL_ADS_COMPLETED, () => { console.log('All ads completed'); this.config.onAllAdsComplete?.(); } ); // Ad error this.adsManager.addEventListener( google.ima.AdErrorEvent.Type.AD_ERROR, (event: any) => this.onAdError(event) ); // Ad skipped this.adsManager.addEventListener( google.ima.AdEvent.Type.SKIPPED, () => { console.log('Ad skipped by user'); } ); // Ad paused (by click-through) this.adsManager.addEventListener( google.ima.AdEvent.Type.PAUSED, () => { console.log('Ad paused (likely from click-through)'); // Don't mark as not playing - keep isAdPlaying true so we can resume } ); // Ad playing (resume after pause) this.adsManager.addEventListener( google.ima.AdEvent.Type.PLAYING, () => { console.log('Ad resumed playing'); } ); } /** * Handle ad error */ private onAdError(event: any): void { const error = event.getError?.(); console.error('Ad error:', error?.getMessage?.() || error); this.config.onAdError?.(error); // Destroy ads manager on error if (this.adsManager) { this.adsManager.destroy(); } // Resume content playback this.isAdPlaying = false; this.video.play().catch(() => { }); } /** * Pause ad */ pause(): void { if (this.adsManager && this.isAdPlaying) { this.adsManager.pause(); } } /** * Resume ad */ resume(): void { if (this.adsManager && this.isAdPlaying) { this.adsManager.resume(); } } /** * Skip ad (if skippable) */ skip(): void { if (this.adsManager) { this.adsManager.skip(); } } /** * Resize ads - with enhanced fullscreen support */ resize(width: number, height: number, viewMode?: any): void { const google = (window as any).google; if (this.adsManager && google && google.ima) { const mode = viewMode || google.ima.ViewMode.NORMAL; console.log(`📐 Resizing ads: ${width}x${height}, ViewMode: ${mode === google.ima.ViewMode.FULLSCREEN ? 'FULLSCREEN' : 'NORMAL'}`); // Force ad container dimensions in fullscreen if (this.adContainer && mode === google.ima.ViewMode.FULLSCREEN) { this.adContainer.style.position = 'fixed'; this.adContainer.style.top = '0'; this.adContainer.style.left = '0'; this.adContainer.style.width = `${width}px`; this.adContainer.style.height = `${height}px`; this.adContainer.style.zIndex = '2147483647'; console.log('✅ Ad container forced to fullscreen dimensions'); } this.adsManager.resize(width, height, mode); } } /** * Set volume */ setVolume(volume: number): void { if (this.adsManager) { this.adsManager.setVolume(volume); } } /** * Check if ad is currently playing */ isPlayingAd(): boolean { return this.isAdPlaying; } /** * Show unmute button overlay (matching player UI style) */ private showUnmuteButton(): void { // Remove existing button if any if (this.unmuteButton) { this.unmuteButton.remove(); } // Create unmute button (matching WebPlayer.ts style) this.unmuteButton = document.createElement('button'); this.unmuteButton.id = 'ad-unmute-btn'; this.unmuteButton.className = 'uvf-unmute-btn'; this.unmuteButton.setAttribute('aria-label', 'Tap to unmute ad'); this.unmuteButton.innerHTML = ` <svg viewBox="0 0 24 24" class="uvf-unmute-icon"> <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/> </svg> <span class="uvf-unmute-text">Tap to unmute</span> `; // Click handler to unmute this.unmuteButton.addEventListener('click', (e) => { e.stopPropagation(); this.toggleAdMute(); }); // Add styles if not already added if (!document.getElementById('uvf-unmute-styles')) { const style = document.createElement('style'); style.id = 'uvf-unmute-styles'; style.textContent = ` .uvf-unmute-btn { position: absolute !important; bottom: 80px !important; left: 20px !important; z-index: 1000 !important; display: flex !important; align-items: center !important; gap: 8px !important; padding: 12px 16px !important; background: rgba(0, 0, 0, 0.8) !important; border: none !important; border-radius: 4px !important; color: white !important; font-size: 14px !important; font-weight: 500 !important; cursor: pointer !important; transition: all 0.2s ease !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important; animation: uvf-unmute-pulse 2s ease-in-out infinite !important; } .uvf-unmute-btn:hover { background: rgba(0, 0, 0, 0.9) !important; transform: scale(1.05) !important; } .uvf-unmute-btn:active { transform: scale(0.95) !important; } .uvf-unmute-icon { width: 20px !important; height: 20px !important; fill: white !important; } .uvf-unmute-text { white-space: nowrap !important; } @keyframes uvf-unmute-pulse { 0%, 100% { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; } 50% { box-shadow: 0 2px 16px rgba(255, 255, 255, 0.2) !important; } } @media (max-width: 767px) { .uvf-unmute-btn { bottom: 70px !important; left: 50% !important; transform: translateX(-50%) !important; padding: 10px 14px !important; font-size: 13px !important; } .uvf-unmute-btn:hover { transform: translateX(-50%) scale(1.05) !important; } } `; document.head.appendChild(style); } // Add to ad container this.adContainer.appendChild(this.unmuteButton); console.log('Unmute button displayed (matching player style)'); } /** * Toggle ad mute state */ private toggleAdMute(): void { this.isMuted = !this.isMuted; if (this.adsManager) { this.adsManager.setVolume(this.isMuted ? 0 : 1); console.log(`Ad ${this.isMuted ? 'muted' : 'unmuted'}`); } // Sync mute state with video element this.video.muted = this.isMuted; // Hide button if unmuted if (!this.isMuted && this.unmuteButton) { this.unmuteButton.remove(); this.unmuteButton = null; } } /** * Resume ad playback after click-through */ private resumeAdPlayback(): void { try { // First, ensure ads manager exists and ad is playing if (!this.adsManager || !this.isAdPlaying) { return; } console.log('Attempting to resume ad playback...'); // Try multiple resume methods try { this.adsManager.resume(); console.log('✅ Ad resume() called'); } catch (e) { console.warn('resume() failed:', e); } // Also try playing the video element directly if (this.video) { try { if (this.video.paused) { const playPromise = this.video.play(); if (playPromise) { playPromise.catch((err) => { console.warn('Video play() failed:', err); }); } console.log('✅ Video play() called'); } } catch (e) { console.warn('Video play failed:', e); } } } catch (error) { console.error('Error resuming ad playback:', error); } } /** * Hide unmute button */ private hideUnmuteButton(): void { if (this.unmuteButton) { this.unmuteButton.remove(); this.unmuteButton = null; } } /** * Cleanup */ destroy(): void { this.hideUnmuteButton(); if (this.adsManager) { this.adsManager.destroy(); this.adsManager = null; } if (this.adsLoader) { this.adsLoader.destroy(); this.adsLoader = null; } this.isAdPlaying = false; } }