UNPKG

vp-outstream-player

Version:

Outstream video player with Google IMA integration

751 lines (620 loc) 19.3 kB
import { PlayerConfig } from "../config/types"; import { ViewabilityTracker } from "./viewability"; import { VastService } from "./vastService"; import { VideoService } from "./videoService"; import { defaultConfig } from "../config/defaultConfig"; import template from "../ui/template"; import { wireControls, updateGradient, setSkin, hideVolumeContainer } from "../ui/controls"; import { isMobile, formatTime, mergeConfigs, loadIMASDK } from "./utility"; const LOADING_DELAY = 750; // ms export class VpOutstreamPlayer { private container: HTMLElement; private fullConfig!: PlayerConfig; private adsObject!: PlayerConfig["ads"]; private videoObject!: PlayerConfig["video"]; private config!: PlayerConfig["config"]; private viewTracker: ViewabilityTracker | null = null; private vastService: VastService | null = null; private videoService: VideoService | null = null; private currentAdIndex = 0; private retryCount = 0; private adCycleTimeout: ReturnType<typeof setTimeout> | null = null; private hasUserInteraction = false; private adBlockerDetected = false; private isVideoMode = false; public playerUi?: Element; public controls?: Element; public playButton?: HTMLElement; public muteButton?: HTMLElement; public skipButton?: HTMLElement; public volumeButton?: HTMLInputElement; private progressBarEl?: HTMLElement; private timeEl?: HTMLElement; private isLoading = false; private adLoaded = false; private isMobile = isMobile(); // Track original and added CSS classes on the container private originalClasses: string[]; private addedClasses: Set<string> = new Set(); /** * Creates a new VpOutstreamPlayer instance tied to a container element. * @param containerId ID of the HTML element to attach the player to. */ constructor(containerId: string) { const el = document.getElementById(containerId); if (!el) throw new Error(`No element found for id=${containerId}`); this.container = el; // Capture initial classes this.originalClasses = Array.from(this.container.classList); } /** Add a class and track it if not original */ private addContainerClass(className: string): void { this.container.classList.add(className); if (!this.originalClasses.includes(className)) { this.addedClasses.add(className); } } /** Toggle a class and track addition if not original */ private toggleContainerClass(className: string, isActive: boolean): void { this.container.classList.toggle(className, isActive); if (isActive && !this.originalClasses.includes(className)) { this.addedClasses.add(className); } } /** * Sets up the outstream player using a configuration object. * Initializes view tracking, player sizing, and loads the first ad break. * @param config Configuration provided by the implementer */ public async setup(config: PlayerConfig): Promise<void> { const resolvedConfig = JSON.parse(JSON.stringify(defaultConfig)); mergeConfigs(resolvedConfig, config, true); this.fullConfig = resolvedConfig; this.config = this.fullConfig.config; this.adsObject = this.fullConfig.ads; this.videoObject = this.fullConfig.video; this.isVideoMode = !!this.videoObject?.url; this.log("Setting up player with config:", this.config); this.setInitialContainerState(); this.setTemplate(); this.registerControls(); this.setSize(); if (this.isVideoMode) { await this.initVideoService(); } else { await this.initVastService(); } this.updateStateClasses(); this.log("Player setup complete"); this.start(); } private setInitialContainerState(): void { this.addContainerClass("vp-outstream-player"); this.hidePlayer(); // Hide the container initially if (this.isMobile) { this.addContainerClass("vp-mobile"); } } setTemplate(): void { this.container.innerHTML = template(); } registerControls(): void { if (!this.config.controls) return; this.playerUi = this.container.querySelector(".vp-player-ui")!; this.controls = this.container.querySelector(".vp-controls")!; for (const [key, enabled] of Object.entries(this.config.controls)) { const controlEl = this.playerUi.querySelector(`.vp-${key}`)!; // Register as this.playButton, this.muteButton etc. (this as any)[`${key}Button`] = controlEl; if (enabled) { controlEl.classList.remove("hidden"); } else { controlEl.classList.add("hidden"); } } // Extra check needed for volume control if (!this.config.controls.mute) { hideVolumeContainer(this.container); } wireControls(this); setSkin(this.container, this.config.skin); this.registerProgressElements(); } private registerProgressElements(): void { this.progressBarEl = this.container.querySelector(".vp-progress-bar") ?? undefined; this.timeEl = this.container.querySelector(".vp-time") ?? undefined; // Render initial values this.updateProgress(); } private updateProgress(): void { const ct = this.currentTime; const dur = this.duration; const pct = dur > 0 ? (ct / dur) * 100 : 0; const timeeLeft = Math.ceil(dur - ct); if (this.progressBarEl) this.progressBarEl.style.width = `${pct}%`; if (this.timeEl) this.timeEl.textContent = formatTime(timeeLeft); } private updateVolume(): void { if (!this.config.controls?.volume) return; const value = this.volumeButton?.value ?? 0; const volume = this.volume; if (this.volumeButton && value !== volume) { this.volumeButton.value = `${volume}`; } if (this.volumeButton) { updateGradient(this.volumeButton); } } setSize(): void { if (this.config.responsive) { this.log("Responsive mode enabled"); this.container.style.width = "100%"; this.container.style.height = "100%"; this.container.style.maxWidth = "100%"; this.container.style.maxHeight = "100%"; return; } if (!this.config.size) return; const { width, height } = this.config.size; this.log(`Setting player size to ${width}x${height}`); Object.assign(this.container.style, { width: `${width}px`, height: `${height}px`, overflow: "hidden", }); } initViewTracking(): void { this.log("Initializing viewability tracking"); const autostartOnViewable = this.config.autostartOnViewable?.state ?? false; const autopauseOnViewable = this.config.autopauseOnViewable?.state ?? false; this.log("autostartOnViewable:", autostartOnViewable); this.log("autopauseOnViewable:", autopauseOnViewable); const onChangeHandler = (inView: boolean) => { if (!inView && autopauseOnViewable) { this.pause(); } else if (inView && autostartOnViewable) { this.play(); } }; this.viewTracker = new ViewabilityTracker(this.container, onChangeHandler); } async initVideoService(): Promise<void> { this.log("Initializing video service"); if (!this.videoObject || !this.videoObject.url) { this.error("No video URL provided"); return; } if (this.videoService) { this.log("Destroying existing VideoService"); this.videoService.destroy(); this.videoService = null; } this.log("Initializing VideoService"); this.videoService = new VideoService(this.container, this.videoObject, { onAdRequested: this.handleAdRequested.bind(this), onAdLoaded: this.onAdLoaded.bind(this), onAdStarted: this.handleAdStarted.bind(this), onAdTimeUpdate: this.handleAdTimeUpdate.bind(this), onAdSkipped: this.handleAdSkipped.bind(this), onAdComplete: this.handleAdComplete.bind(this), onAdError: this.handleAdError.bind(this), onAdResume: this.handleAdResume.bind(this), onAdPause: this.handleAdPause.bind(this), }); this.trackUserInteractionOnce(); } async initVastService(): Promise<void> { // If adblocker has been detected previously by another instance if (window.__vpAdBlocker) { this.warn("AdBlocker detected previously - skipping IMA init"); this.adBlockerDetected = true; return; } try { await loadIMASDK(); this.log("IMA SDK loaded successfully"); } catch (err) { this.warn("IMA SDK load failed - assuming ad-blocker", err); this.adBlockerDetected = true; return; } if (this.adBlockerDetected) return; if (this.vastService) { this.log("Destroying existing VastService"); this.vastService.destroy(); this.vastService = null; } this.log("Initializing VastService"); this.vastService = new VastService(this.container, { onAdRequested: this.handleAdRequested.bind(this), onAdLoaded: this.onAdLoaded.bind(this), onAdStarted: this.handleAdStarted.bind(this), onAdTimeUpdate: this.handleAdTimeUpdate.bind(this), onAdSkipped: this.handleAdSkipped.bind(this), onAdComplete: this.handleAdComplete.bind(this), onAdError: this.handleAdError.bind(this), onAdResume: this.handleAdResume.bind(this), onAdPause: this.handleAdPause.bind(this), }); this.trackUserInteractionOnce(); } /** * Will be overridden. */ trackUserInteractionOnce: () => void = () => {}; /** * Starts the ad cycle from the current index. */ public async start(): Promise<void> { if (this.isVideoMode) { if (!this.videoService || !this.videoObject) { return this.error("No video configuration found"); } this.log("Starting video playback"); this.hasUserInteraction = window.__vpUserInteracted || false; const muted = !this.hasUserInteraction || !!this.config.startMuted; await this.videoService.loadVideo(muted); this.initViewTracking(); return; } // VAST ad mode if (!this.vastService || !this.adsObject.adBreaks.length) return this.error("No ad breaks found"); this.log("Starting ad cycle from index", this.currentAdIndex); this.playAdBreak(this.currentAdIndex); this.initViewTracking(); } public getCurrentAdBreak() { if (this.isVideoMode || !this.adsObject.adBreaks[this.currentAdIndex]) return null; return { index: this.currentAdIndex, adBreak: this.adsObject.adBreaks[this.currentAdIndex], adData: this.vastService?.adData, }; } private playAdBreak(index: number) { if (this.adBlockerDetected) { this.log("Skipping ad request: adBlockerDetected"); return; } const adBreak = this.adsObject.adBreaks[index]; if (!adBreak) return; this.hasUserInteraction = window.__vpUserInteracted || false; const muted = !this.hasUserInteraction || !!this.config.startMuted; this.log(`Playing ad break at index ${index}`, { adBreak, muted: muted, }); this.vastService?.loadAd(adBreak, muted); } /** * Called when an ad plays successfully to schedule the next ad. */ private scheduleNextAd(): void { if (this.isVideoMode) { // No scheduling for video mode return; } this.retryCount = 0; this.currentAdIndex++; this.log(`Ad completed. Scheduling next ad (index ${this.currentAdIndex})`); if (this.currentAdIndex >= this.adsObject.adBreaks.length) { this.log("Reached end of adBreaks"); if (this.adsObject.adCycleRestartMs && this.adsObject.adCycleRestartMs >= 0) { this.log(`Restarting ad cycle after ${this.adsObject.adCycleRestartMs}ms`); this.adCycleTimeout = setTimeout(() => { this.currentAdIndex = 0; this.start(); }, this.adsObject.adCycleRestartMs); } return; } this.log(`Scheduling next ad in ${this.adsObject.adCycleDelayMs}ms`); this.adCycleTimeout = setTimeout(() => { this.playAdBreak(this.currentAdIndex); }, this.adsObject.adCycleDelayMs); } /** * Called when an ad request is initiated. */ private handleAdRequested(): void { this.log("Ad requested"); this.adLoaded = false; this.showPlayer(); // Only show the loading spinner if the ad hasn't started within 500ms this.callAfter(() => { if (this.adLoaded) return; this.showLoading(); }, LOADING_DELAY); this.fullConfig.onAdRequested?.(); } /** * Called when the VAST response has been loaded successfully. */ private onAdLoaded(): void { this.log("Ad loaded"); this.adLoaded = true; this.showUi(); this.updateStateClasses(); this.hideLoading(); this.fullConfig.onAdLoaded?.(); } /** * Called when the ad actually begins playback. * - If the player isn't currently viewable, immediately pauses */ private handleAdStarted(): void { this.log("Ad started"); if (!this.isViewable) { this.log("Ad not viewable - pausing"); this.pause(); } this.fullConfig.onAdStarted?.(); } /** * Called periodically with the current playback time (in seconds). * @param currentTime Number of seconds elapsed in the current ad */ private handleAdTimeUpdate(currentTime: number): void { this.updateProgress(); this.updateVolume(); this.updateStateClasses(); this.fullConfig.onAdTimeUpdate?.(currentTime); } /** * Handles ad skipping and optionally hides the player. * If the ad is skipped, it will also schedule the next ad. */ private handleAdSkipped(): void { if (this.config.hideWhenNoAd && !this.isVideoMode) { this.hidePlayer(); } this.log("Ad skipped"); this.hideUi(); if (!this.isVideoMode) { this.scheduleNextAd(); } this.fullConfig.onAdSkipped?.(); } /** * Handles ad completion and optionally hides the player. */ private handleAdComplete(): void { if (this.config.hideWhenNoAd && !this.isVideoMode) { this.hidePlayer(); } this.log("Ad completed"); this.hideUi(); if (!this.isVideoMode) { this.scheduleNextAd(); } this.fullConfig.onAdComplete?.(); } /** * Handles ad error events and optionally hides the player. */ private handleAdError(err: Error): void { if (this.config.hideWhenNoAd && !this.isVideoMode) { this.hidePlayer(); } this.error("Ad error:", err); if (!this.isVideoMode) { this.log(`Retry count: ${this.retryCount} / ${this.adsObject.adRetryLimit}`); if (this.retryCount < (this.adsObject.adRetryLimit ?? 0)) { this.retryCount++; this.playAdBreak(this.currentAdIndex); // retry same index } else { this.retryCount = 0; this.currentAdIndex++; this.playAdBreak(this.currentAdIndex); // move on to next ad } } this.fullConfig.onAdError?.(err); } private handleAdResume(): void { this.log("Ad resumed"); this.updateStateClasses(); this.fullConfig.onAdResume?.(); } private handleAdPause(): void { this.log("Ad paused"); this.updateStateClasses(); this.fullConfig.onAdPause?.(); } private hideUi(): void { if (!this.playerUi) return; this.log("Hiding player UI"); this.playerUi.classList.add("hidden"); } private showUi(): void { if (!this.playerUi) return; this.resetUi(); this.log("Showing player UI"); this.playerUi.classList.remove("hidden"); } resetUi(): void { if (!this.playerUi) return; this.updateProgress(); this.updateVolume(); this.updateStateClasses(); } private hidePlayer(): void { this.container.style.display = "none"; this.log("Player hidden"); } private showPlayer(): void { this.container.style.display = "block"; this.log("Player shown"); } private showLoading(): void { if (!this.playerUi) return; this.isLoading = true; this.updateStateClasses(); } private hideLoading(): void { if (!this.playerUi) return; this.isLoading = false; this.updateStateClasses(); } private log(...args: any[]): void { if (this.config?.debug) { console.log("[VpOutstreamPlayer]", ...args); } } private warn(...args: any[]): void { console.warn("[VpOutstreamPlayer]", ...args); } private error(...args: any[]): void { console.error("[VpOutstreamPlayer]", ...args); } /** * plays the ad if applicable. */ public play(): void { if (this.isVideoMode) { this.videoService?.play(); } else { this.vastService?.resumeAd(); } this.updateStateClasses(); } /** * Pauses the ad if applicable. */ public pause(): void { if (this.isVideoMode) { this.videoService?.pause(); } else { this.vastService?.pauseAd(); } this.updateStateClasses(); } /** * Unmutes the ad playback. */ public unmute(): void { if (this.isVideoMode) { this.videoService?.setMuted(false); } else { this.vastService?.setMuted(false); } this.updateVolume(); this.updateStateClasses(); } /** * Mutes the ad playback. */ public mute(): void { if (this.isVideoMode) { this.videoService?.setMuted(true); } else { this.vastService?.setMuted(true); } this.updateVolume(); this.updateStateClasses(); } /** * Sets the volume of the ad playback. * @param volume Volume level between 0 and 1. */ public setVolume(volume: number): void { if (this.isVideoMode) { this.videoService?.setVolume(volume); } else { this.vastService?.setVolume(volume); } this.updateVolume(); this.updateStateClasses(); } public skipAd(): void { if (this.isVideoMode) { this.videoService?.skipAd(); } else { this.vastService?.skipAd(); } } get isPlaying(): boolean { return this.isVideoMode ? this.videoService?.isPlaying ?? false : this.vastService?.isPlaying ?? false; } get isMuted(): boolean { return this.isVideoMode ? this.videoService?.isMuted ?? false : this.vastService?.isMuted ?? false; } get isViewable(): boolean { return this.viewTracker?.isInViewState ?? false; } get volume(): number { return this.isVideoMode ? this.videoService?.volume ?? 0 : this.vastService?.volume ?? 0; } get currentTime(): number { return this.isVideoMode ? this.videoService?.currentTime ?? 0 : this.vastService?.currentTime ?? 0; } get duration(): number { return this.isVideoMode ? this.videoService?.duration ?? 0 : this.vastService?.duration ?? 0; } get adData(): google.ima.AdData | undefined { return this.vastService?.adData; } public getState(): Record<string, boolean | number> { return { isMuted: this.isMuted, isPlaying: this.isPlaying, volume: this.volume, duration: this.duration, currentTime: this.currentTime, loading: this.isLoading, isVideoMode: this.isVideoMode, }; } private updateStateClasses(): void { if (!this.container) return; const stateMap: Record<string, boolean> = { "is-muted": this.isMuted, "is-playing": this.isPlaying, "is-loading": this.isLoading, "is-video-mode": this.isVideoMode, }; Object.entries(stateMap).forEach(([className, isActive]) => { this.toggleContainerClass(className, isActive); }); } private callAfter(callback: () => void, delay: number): void { const timer = setTimeout(() => { callback(); clearTimeout(timer); }, delay); } /** * Destroys the player, cleaning up observers and ad managers. */ public destroy(): void { this.viewTracker?.destroy(); this.vastService?.destroy(); this.videoService?.destroy(); if (this.adCycleTimeout) { clearTimeout(this.adCycleTimeout); this.adCycleTimeout = null; } // Remove any classes the player added this.addedClasses.forEach((className) => this.container.classList.remove(className)); // Clean up content this.container.innerHTML = ""; this.log("Player destroyed"); } public async reSetup(): Promise<void> { this.log("Re-setting up player with existing config"); const config = this.config; const containerId = this.container.id; this.destroy(); const newPlayer = window.vpOutstreamPlayer(containerId); if (newPlayer) { await newPlayer.setup(config); this.log("Player re-setup complete"); } } }