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
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
// 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;
}
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;
}
}
`;
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;
}
}