UNPKG

unified-video-framework

Version:

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

1,155 lines (995 loc) โ€ข 39.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 // Periodic ad breaks (for live streams or automatic scheduling) liveAdBreakMode?: 'periodic' | 'manual'; // 'periodic' = auto-schedule at intervals, 'manual' = use midrollTimes periodicAdInterval?: number; // Interval in seconds between ads (e.g., 30 = ad every 30 seconds) syncToLiveEdge?: boolean; // For live streams: sync back to live edge after ad pauseStreamDuringAd?: boolean; // Pause underlying stream during ad playback liveEdgeOffset?: number; // Seconds behind live edge to resume at (default: 3) // 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; private adProgressBar: HTMLElement | null = null; private adProgressInterval: any = null; // Periodic ad scheduling private lastAdTime = 0; private periodicAdCheckInterval: any = null; private pendingAdRequest = false; // Bound event handler references for proper cleanup private focusHandler: () => void; private visibilityHandler: () => void; private timeupdateHandler: (() => void) | null = null; private volumechangeHandler: (() => void) | null = null; // Mid-roll tracking for explicit midrollTimes private triggeredMidrollTimes: Set<number> = new Set(); constructor(video: HTMLVideoElement, adContainer: HTMLElement, config: GoogleAdsConfig) { this.video = video; this.adContainer = adContainer; this.config = config; // Bind handlers so they can be removed on destroy this.focusHandler = () => { if (this.isAdPlaying) { console.log('Window focused - resuming ad playback'); this.resumeAdPlayback(); setTimeout(() => this.resumeAdPlayback(), 200); } }; this.visibilityHandler = () => { if (!document.hidden && this.isAdPlaying) { console.log('Tab became visible - resuming ad playback'); this.resumeAdPlayback(); } }; // 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', this.focusHandler); document.addEventListener('visibilitychange', this.visibilityHandler); } /** * 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; // Validate ad container has non-zero dimensions const rect = this.adContainer.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) { console.warn('โš ๏ธ Ad container has zero dimensions:', rect.width, 'x', rect.height); console.warn('IMA UI may not render correctly. Ensure container is visible before initialization.'); } // 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(); } }); // Setup mid-roll triggers for explicitly provided midrollTimes this.setupMidrollTriggers(); } /** * Setup timeupdate-based mid-roll triggers for explicit midrollTimes. * Without this, midrollTimes only shows visual markers but never fires actual ads. */ private setupMidrollTriggers(): void { if (!this.config.midrollTimes || this.config.midrollTimes.length === 0) { return; } console.log(`๐Ÿ“บ Setting up mid-roll triggers for times: ${this.config.midrollTimes.join(', ')}s`); this.timeupdateHandler = () => { if (this.isAdPlaying || this.pendingAdRequest) return; const currentTime = this.video.currentTime; for (const triggerTime of this.config.midrollTimes!) { // Trigger when currentTime crosses the target within a 1-second window if (!this.triggeredMidrollTimes.has(triggerTime) && currentTime >= triggerTime && currentTime < triggerTime + 2) { console.log(`โฐ Mid-roll triggered at ${currentTime.toFixed(1)}s (scheduled: ${triggerTime}s)`); this.triggeredMidrollTimes.add(triggerTime); this.pendingAdRequest = true; this.lastAdTime = currentTime; if (this.config.pauseStreamDuringAd && !this.video.paused) { this.video.pause(); } this.requestAds(); break; } } }; this.video.addEventListener('timeupdate', this.timeupdateHandler); } /** * Setup periodic ad breaks (for live streams or auto-scheduling) */ setupPeriodicAds(): void { // Only setup if periodic mode is enabled if (this.config.liveAdBreakMode !== 'periodic' || !this.config.periodicAdInterval) { return; } // Guard: only one interval allowed โ€” each requestAds() cycle calls onAdsManagerLoaded() // which calls setupPeriodicAds() again; without this guard, intervals multiply. if (this.periodicAdCheckInterval !== null) { return; } console.log(`๐Ÿ“บ Setting up periodic ads: interval=${this.config.periodicAdInterval}s`); // Check every second if we should trigger an ad this.periodicAdCheckInterval = setInterval(() => { // Skip if ad is already playing or pending if (this.isAdPlaying || this.pendingAdRequest) { return; } const currentTime = this.video.currentTime; const interval = this.config.periodicAdInterval || 30; // Check if enough time has passed since last ad if (currentTime - this.lastAdTime >= interval && currentTime > 0) { console.log(`โฐ Periodic ad triggered at ${currentTime.toFixed(1)}s (interval: ${interval}s)`); this.triggerPeriodicAd(); } }, 1000); // Check every second console.log('โœ… Periodic ad scheduling enabled'); } /** * Trigger a periodic ad break */ private triggerPeriodicAd(): void { if (this.pendingAdRequest || this.isAdPlaying) { return; } this.pendingAdRequest = true; this.lastAdTime = this.video.currentTime; console.log('๐ŸŽฌ Triggering periodic ad break...'); // Pause video if configured if (this.config.pauseStreamDuringAd && !this.video.paused) { this.video.pause(); } // Request a new ad this.requestAds(); } /** * Request ads */ requestAds(): void { const google = (window as any).google; // Guard: don't call on a destroyed instance if (!this.adsLoader) { console.warn('requestAds() called but adsLoader is null (destroyed?)'); this.pendingAdRequest = false; return; } 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 => { const container = document.getElementById(slot.containerId); if (!container) return null; return new google.ima.CompanionAdSelectionSettings(); }).filter(Boolean); if (companionAdSlots.length > 0) { adsRequest.companionSlots = companionAdSlots; } } // 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); // Reset pending flag so future ad breaks are not permanently blocked this.pendingAdRequest = false; 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; // CRITICAL: Don't set uiElements at all - let IMA use its defaults // Setting uiElements (even to []) can disable UI in some IMA SDK versions // By not setting it, IMA SDK will render its default UI overlay // This includes: Advertisement label, countdown timer, learn more button, etc. // Reference: https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/reference/js/google.ima.AdsRenderingSettings#uiElements // NOTE: If uiElements is left unset, IMA SDK version determines default behavior // Most modern IMA SDK versions (3.x+) show all UI elements by default // Enable styled linear ads for better UI rendering adsRenderingSettings.useStyledLinearAds = true; // Check IMA SDK version and available UI elements console.log('๐Ÿ“Š IMA SDK Info:', { version: google.ima.VERSION || 'unknown', hasUiElements: !!google.ima.UiElements, availableUiElements: google.ima.UiElements ? Object.keys(google.ima.UiElements) : [] }); console.log('โœ… IMA AdsRenderingSettings configured:', { uiElements: adsRenderingSettings.uiElements || 'not set (using IMA defaults)', enablePreloading: adsRenderingSettings.enablePreloading, useStyledLinearAds: adsRenderingSettings.useStyledLinearAds, mute: adsRenderingSettings.mute, restoreCustomPlaybackStateOnAdBreakComplete: adsRenderingSettings.restoreCustomPlaybackStateOnAdBreakComplete }); // 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(); // Setup periodic ad scheduling if enabled (guard inside prevents duplicate intervals) this.setupPeriodicAds(); } catch (error) { console.error('Error starting ads:', error); // Reset state so the pipeline is not permanently stuck this.pendingAdRequest = false; this.isAdPlaying = false; 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.pendingAdRequest = false; // Reset pending flag 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'); // Diagnostic: Check for IMA UI elements after a short delay setTimeout(() => { const imaElements = this.adContainer.querySelectorAll('[class*="ima-"], [id*="ima-"]'); const allDivs = this.adContainer.querySelectorAll('div'); // Find and force-show any "Advertisement" labels let advertisementLabelFound = false; allDivs.forEach((div: HTMLElement) => { const text = div.textContent?.trim() || ''; if (text === 'Advertisement' || text === 'Ad' || text.startsWith('Advertisement')) { console.log('๐Ÿ“ข Found Advertisement label:', { text, visible: div.offsetWidth > 0 && div.offsetHeight > 0, display: getComputedStyle(div).display, position: getComputedStyle(div).position }); // Force it to be visible div.style.display = 'block'; div.style.visibility = 'visible'; div.style.opacity = '1'; div.style.position = 'absolute'; div.style.top = '10px'; div.style.left = '10px'; div.style.zIndex = '999999'; div.style.color = 'white'; div.style.background = 'rgba(0, 0, 0, 0.6)'; div.style.padding = '4px 8px'; div.style.fontSize = '12px'; div.style.fontFamily = 'Arial, sans-serif'; advertisementLabelFound = true; } }); console.log('๐Ÿ” IMA UI Diagnostic:', { totalDivs: allDivs.length, imaElements: imaElements.length, containerChildren: this.adContainer.children.length, adContainerSize: `${this.adContainer.offsetWidth}x${this.adContainer.offsetHeight}`, advertisementLabelFound }); if (!advertisementLabelFound) { console.warn('โš ๏ธ Advertisement label not found in DOM - creating custom overlay'); // Create custom "Advertisement" label for compliance const adLabel = document.createElement('div'); adLabel.id = 'uvf-custom-ad-label'; adLabel.textContent = 'Advertisement'; adLabel.style.cssText = ` position: absolute !important; top: 10px !important; left: 10px !important; z-index: 2147483647 !important; color: white !important; background: rgba(0, 0, 0, 0.7) !important; padding: 4px 10px !important; font-size: 12px !important; font-family: Arial, Roboto, sans-serif !important; font-weight: 500 !important; letter-spacing: 0.5px !important; border-radius: 2px !important; pointer-events: none !important; user-select: none !important; `; this.adContainer.appendChild(adLabel); console.log('โœ… Custom Advertisement label created and displayed'); } if (imaElements.length === 0) { console.warn('โš ๏ธ No IMA UI elements found! UI may not be rendering.'); console.warn('This could mean: 1) Ad creative has no UI, 2) uiElements config issue, 3) IMA SDK version issue'); } else { console.log('โœ… IMA UI elements detected:', Array.from(imaElements).map(el => el.className || el.id)); } }, 500); } // 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; // Sync player volume/mute changes to IMA during ad playback this.volumechangeHandler = () => { if (!this.adsManager || !this.isAdPlaying) return; const vol = this.video.muted ? 0 : this.video.volume; this.adsManager.setVolume(vol); const wasMuted = this.isMuted; this.isMuted = this.video.muted || this.video.volume === 0; if (wasMuted !== this.isMuted) { this.updateMuteButtonUI(); } }; this.video.addEventListener('volumechange', this.volumechangeHandler); 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 custom Advertisement label const customLabel = this.adContainer.querySelector('#uvf-custom-ad-label'); if (customLabel) { customLabel.remove(); } // Hide ad progress bar this.hideAdProgressBar(); // Remove play blocker const preventPlayDuringAd = (this.video as any).__adPlayBlocker; if (preventPlayDuringAd) { this.video.removeEventListener('play', preventPlayDuringAd); delete (this.video as any).__adPlayBlocker; } // Remove volume sync listener if (this.volumechangeHandler) { this.video.removeEventListener('volumechange', this.volumechangeHandler); this.volumechangeHandler = null; } 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(); } // Show ad progress bar this.showAdProgressBar(ad.getDuration()); } ); // 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(); this.adsManager = null; } // Reset all ad state this.isAdPlaying = false; this.pendingAdRequest = false; // Remove the play blocker if it was installed (would block all future playback otherwise) const blocker = (this.video as any).__adPlayBlocker; if (blocker) { this.video.removeEventListener('play', blocker); delete (this.video as any).__adPlayBlocker; } // Hide unmute button if showing this.hideUnmuteButton(); // Resume content playback 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; } } /* Protect IMA native UI elements from being hidden by parent application CSS */ .uvf-ad-container [class*="ima-"], .uvf-ad-container [id*="ima-"] { visibility: visible !important; opacity: 1 !important; } /* Ensure IMA control containers are visible */ .uvf-ad-container .ima-ad-container, .uvf-ad-container .ima-controls-div, .uvf-ad-container .ima-countdown-div, .uvf-ad-container .ima-text-div { display: block !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important; } /* CRITICAL: Force Advertisement label visibility (usually top-left) */ .uvf-ad-container .ima-ad-container [class*="text"], .uvf-ad-container .ima-ad-container [class*="label"], .uvf-ad-container .ima-ad-container [class*="attribution"] { display: block !important; visibility: visible !important; opacity: 1 !important; position: relative !important; z-index: 999999 !important; } /* CRITICAL: Force countdown timer visibility */ .uvf-ad-container .ima-ad-container [class*="countdown"], .uvf-ad-container .ima-ad-container [class*="time"], .uvf-ad-container .ima-ad-container [class*="remaining"] { display: block !important; visibility: visible !important; opacity: 1 !important; position: relative !important; z-index: 999999 !important; } /* Force all text elements in ad container to be visible */ .uvf-ad-container * { max-height: none !important; max-width: none !important; } /* CRITICAL: Advertisement label - typically top-left corner */ /* This is required by Google Ads policy and FTC regulations */ .uvf-ad-container div[style*="top"][style*="left"] { display: block !important; visibility: visible !important; opacity: 1 !important; } /* Force any element containing "Advertisement" or "Ad" text to show */ .uvf-ad-container div:has-text("Advertisement"), .uvf-ad-container div:has-text("Ad ยท"), .uvf-ad-container span:has-text("Advertisement") { display: block !important; visibility: visible !important; opacity: 1 !important; color: white !important; background: rgba(0, 0, 0, 0.6) !important; padding: 4px 8px !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. Restores original volume on unmute; toggles button icon. */ private toggleAdMute(): void { this.isMuted = !this.isMuted; if (this.adsManager) { // Restore actual player volume on unmute instead of jumping to 100% this.adsManager.setVolume(this.isMuted ? 0 : (this.video.volume || 1)); console.log(`Ad ${this.isMuted ? 'muted' : 'unmuted'}`); } // Sync mute state with video element (fires volumechangeHandler โ€” harmless) this.video.muted = this.isMuted; // Toggle button icon so user can always re-mute/re-unmute this.updateMuteButtonUI(); } /** * Update the mute toggle button icon/label to reflect current isMuted state. * Creates the button if muted and not yet shown; keeps it visible when unmuted * so the user can re-mute. */ private updateMuteButtonUI(): void { if (!this.unmuteButton) { if (this.isMuted) this.showUnmuteButton(); return; } if (this.isMuted) { 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> `; } else { // Ad is now unmuted โ€” hide the overlay entirely, never show "Tap to mute" this.hideUnmuteButton(); } } /** * Resume ad playback after click-through. * Only calls adsManager.resume() โ€” do NOT call video.play() directly here because * the __adPlayBlocker listener installed in CONTENT_PAUSE_REQUESTED would immediately * re-pause the video element, defeating the resume. IMA SDK owns the video element * during ad playback and handles resumption internally via adsManager.resume(). */ private resumeAdPlayback(): void { try { if (!this.adsManager || !this.isAdPlaying) { return; } console.log('Attempting to resume ad playback...'); try { this.adsManager.resume(); console.log('โœ… Ad resume() called'); } catch (e) { console.warn('resume() 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; } } /** * Show ad progress bar (read-only visual indicator) */ private showAdProgressBar(duration: number): void { // Remove existing progress bar if any this.hideAdProgressBar(); // Create progress bar container const container = document.createElement('div'); container.id = 'uvf-ad-progress-container'; container.style.cssText = ` position: absolute !important; bottom: 0px !important; left: 0px !important; right: 0px !important; height: 4px !important; background: rgba(0, 0, 0, 0.4) !important; overflow: hidden !important; z-index: 2147483646 !important; pointer-events: none !important; `; // Create progress bar fill const fill = document.createElement('div'); fill.id = 'uvf-ad-progress-fill'; fill.style.cssText = ` position: absolute !important; left: 0 !important; top: 0 !important; height: 100% !important; width: 0% !important; background: #FFC107 !important; box-shadow: 0 0 8px rgba(255, 193, 7, 0.6) !important; transition: width 0.1s linear !important; `; container.appendChild(fill); this.adContainer.appendChild(container); this.adProgressBar = container; console.log('โœ… Ad progress bar created'); // Update progress every 100ms let currentTime = 0; this.adProgressInterval = setInterval(() => { if (!this.adsManager || !this.isAdPlaying) { this.hideAdProgressBar(); return; } try { // Get current ad time from IMA SDK const ad = this.adsManager.getCurrentAd(); if (ad) { currentTime = this.adsManager.getRemainingTime(); const elapsed = duration - currentTime; const progress = Math.min(100, Math.max(0, (elapsed / duration) * 100)); const fillEl = document.getElementById('uvf-ad-progress-fill'); if (fillEl) { fillEl.style.width = `${progress}%`; } } } catch (e) { // Silently handle errors (ad might have ended) } }, 100); } /** * Hide ad progress bar */ private hideAdProgressBar(): void { if (this.adProgressInterval) { clearInterval(this.adProgressInterval); this.adProgressInterval = null; } if (this.adProgressBar) { this.adProgressBar.remove(); this.adProgressBar = null; } } /** * Cleanup */ destroy(): void { this.hideUnmuteButton(); this.hideAdProgressBar(); // Remove focus/visibility handlers window.removeEventListener('focus', this.focusHandler); document.removeEventListener('visibilitychange', this.visibilityHandler); // Remove timeupdate mid-roll handler if (this.timeupdateHandler) { this.video.removeEventListener('timeupdate', this.timeupdateHandler); this.timeupdateHandler = null; } // Remove volume sync handler if ad was destroyed mid-playback if (this.volumechangeHandler) { this.video.removeEventListener('volumechange', this.volumechangeHandler); this.volumechangeHandler = null; } // Remove play blocker if lingering const blocker = (this.video as any).__adPlayBlocker; if (blocker) { this.video.removeEventListener('play', blocker); delete (this.video as any).__adPlayBlocker; } // Clear periodic ad interval if (this.periodicAdCheckInterval) { clearInterval(this.periodicAdCheckInterval); this.periodicAdCheckInterval = null; console.log('โœ… Periodic ad scheduling stopped'); } if (this.adsManager) { this.adsManager.destroy(); this.adsManager = null; } if (this.adsLoader) { this.adsLoader.destroy(); this.adsLoader = null; } this.isAdPlaying = false; this.pendingAdRequest = false; this.triggeredMidrollTimes.clear(); } }