vp-outstream-player
Version:
Outstream video player with Google IMA integration
751 lines (620 loc) • 19.3 kB
text/typescript
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");
}
}
}