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
text/typescript
/**
* 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;
}
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;
}
}
(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();
}
}