vp-outstream-player
Version:
Outstream video player with Google IMA integration
1,454 lines (1,447 loc) • 90 kB
JavaScript
class ViewabilityTracker {
element;
onChange;
observer = null;
isInView = false;
constructor(element, onChange) {
this.element = element;
this.onChange = onChange;
if ("IntersectionObserver" in window) {
const options = {
rootMargin: "0px",
threshold: 0.5,
};
this.observer = new IntersectionObserver(([entry]) => {
this.isInView = entry.isIntersecting;
this.onChange(this.isInView);
}, options);
this.observer.observe(element);
}
else {
// Fallback if no IntersectionObserver
this.isInView = true;
this.onChange(true);
}
}
destroy() {
if (this.observer) {
this.observer.disconnect();
}
}
get isInViewState() {
return this.isInView;
}
}
// Turn multiple ad URLs into a "waterfall" XML
function getVAST_XML(waterfallArray) {
if (!Array.isArray(waterfallArray) || waterfallArray.length === 0) {
return "";
}
const startLine = `<VAST version="3.0">`;
const endLine = `</VAST>`;
let xml = startLine;
let valid = false;
waterfallArray.forEach((adUrl, index) => {
if (adUrl.trim() === "")
return; // Skip empty URLs
valid = true;
xml += `<Ad id="${index + 1}">
<Wrapper>
<AdSystem>AD_SYSTEM</AdSystem>
<VASTAdTagURI><![CDATA[${adUrl}]]></VASTAdTagURI>
<Extensions>
<Extension type="waterfall" fallback_index="${index}"/>
</Extensions>
</Wrapper>
</Ad>`;
});
xml += endLine;
return valid ? xml : "";
}
/**
* This service class is in charge of:
* - Initializing the Google IMA SDK
* - Requesting ads using either single VAST or waterfall
* - Firing callbacks when relevant
*/
class VastService {
playerContainer;
container;
callbacks;
adsLoader;
adsManager;
adDisplayContainer;
playing = false;
currentAdData;
constructor(playerContainer, callbacks) {
this.playerContainer = playerContainer;
this.container = this.playerContainer.querySelector(".vp-ad-container");
this.callbacks = callbacks;
}
loadAd(adBreak, muted) {
if (!this.isImaSdkLoaded()) {
this.callbacks.onAdError?.(new Error("Google IMA SDK not loaded"));
return;
}
// Build final adTagUrl, handle waterfall
let adTagUrl = "";
let type = "url";
if (Array.isArray(adBreak.adTagUrl)) {
adTagUrl = getVAST_XML(adBreak.adTagUrl);
type = "xml";
}
else {
adTagUrl = adBreak.adTagUrl;
}
// If there's no valid adTagUrl, skip
if (!adTagUrl) {
this.callbacks.onAdError?.(new Error("No valid adTagUrl"));
return;
}
// If the IMA SDK script isn't loaded yet, do that (or assume it is in index.html).
// For brevity, we'll assume google.ima is available globally.
// 0. Clear previous ad data
this.resetAdData();
// 1. Create AdDisplayContainer
this.adDisplayContainer = new window.google.ima.AdDisplayContainer(this.container);
// Check for proper method
if (!this.adDisplayContainer || !this.adDisplayContainer.initialize) {
this.callbacks.onAdError?.(new Error("AdDisplayContainer not created"));
return;
}
this.adDisplayContainer.initialize?.();
// 2. Create AdsLoader
this.adsLoader = new window.google.ima.AdsLoader(this.adDisplayContainer);
// Check for proper method
if (!this.adsLoader || !this.adsLoader.requestAds) {
this.callbacks.onAdError?.(new Error("AdsLoader not created"));
return;
}
// Listen for adsManagerLoaded or error events
this.adsLoader.addEventListener(window.google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, (evt) => this.onAdsManagerLoaded(evt, muted));
this.adsLoader.addEventListener(window.google.ima.AdErrorEvent.Type.AD_ERROR, (evt) => {
this.callbacks.onAdError?.(evt.getError());
});
// 3. Build AdsRequest
const adsRequest = new window.google.ima.AdsRequest();
if (type === "xml") {
adsRequest.adsResponse = adTagUrl;
}
else {
adsRequest.adTagUrl = adTagUrl;
}
adsRequest.linearAdSlotWidth = this.container.offsetWidth;
adsRequest.linearAdSlotHeight = this.container.offsetHeight;
adsRequest.setAdWillPlayMuted(muted);
this.callbacks.onAdRequested?.();
this.adsLoader.requestAds(adsRequest);
}
isImaSdkLoaded() {
return !!window.google && !!window.google.ima;
}
onAdsManagerLoaded(evt, muted) {
this.adsManager = evt.getAdsManager();
// Subscribe to adsManager events
this.adsManager.addEventListener(window.google.ima.AdEvent.Type.LOADED, this.onAdLoaded.bind(this));
this.adsManager.addEventListener(window.google.ima.AdEvent.Type.STARTED, this.onAdStarted.bind(this));
this.adsManager.addEventListener(window.google.ima.AdEvent.Type.AD_PROGRESS, this.onTimeUpdate.bind(this));
this.adsManager.addEventListener(window.google.ima.AdEvent.Type.SKIPPED, this.onAdSkipped?.bind(this));
this.adsManager.addEventListener(window.google.ima.AdEvent.Type.COMPLETE, this.onadComplete.bind(this));
this.adsManager.addEventListener(window.google.ima.AdErrorEvent.Type.AD_ERROR, this.onAdError.bind(this));
this.adsManager.addEventListener(window.google.ima.AdEvent.Type.RESUMED, this.onAdResume.bind(this));
this.adsManager.addEventListener(window.google.ima.AdEvent.Type.PAUSED, this.onAdPause.bind(this));
try {
// Initialize and start ad
this.adsManager.init(this.container.offsetWidth, this.container.offsetHeight, window.google.ima.ViewMode.NORMAL);
this.adsManager.setVolume(muted ? 0 : 1);
this.adsManager.start();
}
catch (err) {
this.callbacks.onAdError?.(new Error("AdsManager Error: " + err));
}
}
setMuted(mute) {
if (this.adsManager) {
this.adsManager.setVolume(mute ? 0 : 1);
}
}
resetAdData() {
this.currentAdData = undefined;
if (this.adsManager) {
this.adsManager.destroy();
this.adsManager = undefined;
}
if (this.adsLoader) {
this.adsLoader.destroy();
this.adsLoader = undefined;
}
if (this.adDisplayContainer) {
this.adDisplayContainer.destroy();
this.adDisplayContainer = undefined;
}
this.playing = false;
}
onAdLoaded() {
this.callbacks.onAdLoaded?.();
}
onAdStarted() {
this.playing = true;
this.callbacks.onAdStarted?.();
}
onTimeUpdate(event) {
const adData = event.getAdData();
if (!adData)
return;
this.currentAdData = adData;
if (adData.currentTime === undefined)
return;
this.callbacks.onAdTimeUpdate?.(adData.currentTime);
}
onAdSkipped() {
this.resetAdData();
this.callbacks.onAdSkipped?.();
}
onadComplete() {
this.resetAdData();
this.callbacks.onAdComplete?.();
}
onAdError(err) {
this.resetAdData();
this.callbacks.onAdError?.(err);
}
onAdResume() {
this.playing = true;
this.callbacks.onAdResume?.();
}
onAdPause() {
this.playing = false;
this.callbacks.onAdPause?.();
}
pauseAd() {
if (this.adsManager) {
this.adsManager.pause();
this.playing = false;
}
}
resumeAd() {
if (this.adsManager) {
this.adsManager.resume();
this.playing = true;
}
}
setVolume(volume) {
if (this.adsManager) {
this.adsManager.setVolume(volume);
}
}
skipAd() {
if (this.adsManager) {
this.adsManager.skip();
this.playing = false;
}
}
get isPlaying() {
return this.playing;
}
get isMuted() {
return this.adsManager ? this.adsManager.getVolume() === 0 : false;
}
get volume() {
return this.adsManager ? this.adsManager.getVolume() : 0;
}
get currentTime() {
return this.currentAdData ? this.currentAdData.currentTime : 0;
}
get duration() {
return this.currentAdData ? this.currentAdData.duration : 0;
}
get adData() {
return this.currentAdData;
}
destroy() {
if (this.adsManager) {
this.adsManager.destroy();
this.adsManager = undefined;
}
if (this.adsLoader) {
this.adsLoader = undefined;
}
}
}
/**
* Returns a debounced version of `fn` that only fires after `wait` ms
* have elapsed since the last call.
*/
/**
* Turns seconds into "MM:SS" format.
*/
function formatTime(seconds) {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const s = Math.floor(seconds % 60)
.toString()
.padStart(2, "0");
return `${m}:${s}`;
}
/**
* Recursively merge `overrides` into `defaults`, only overriding
* if types match. Nulls override when `nullCanOverride` is true.
*/
function mergeConfigs(defaults, overrides, nullCanOverride = false) {
for (const key in defaults) {
if (!(key in overrides))
continue;
const ov = overrides[key];
const df = defaults[key];
if (ov === null) {
if (nullCanOverride)
defaults[key] = null;
continue;
}
if (typeof df === "object" &&
typeof ov === "object" &&
!Array.isArray(df) &&
!Array.isArray(ov)) {
mergeConfigs(df, ov, nullCanOverride);
}
else if (typeof ov === typeof df) {
defaults[key] = ov;
}
}
}
/**
* Loads the HLS.js library if it isn't already on window.Hls.
* Resolves when the library is loaded, rejects if load fails.
*/
function loadHLSScript() {
return new Promise((resolve, reject) => {
if (window.Hls) {
resolve();
return;
}
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/hls.js@latest";
script.async = true;
script.onload = () => {
if (window.Hls)
resolve();
else
reject(new Error("HLS.js loaded but window.Hls missing"));
};
script.onerror = () => {
reject(new Error("Failed to load HLS.js"));
};
document.head.appendChild(script);
});
}
/**
* Loads the Google IMA SDK if it isn't already on window.google.ima.
* Resolves when the SDK is present, rejects (and sets __vpAdBlocker)
* if the network load fails (e.g. blocked by an ad‑blocker).
*/
function loadIMASDK() {
return new Promise((resolve, reject) => {
if (window.google?.ima) {
resolve();
return;
}
const script = document.createElement("script");
script.src = "https://imasdk.googleapis.com/js/sdkloader/ima3.js";
script.async = true;
script.onload = () => {
if (window.google?.ima)
resolve();
else
reject(new Error("IMA loaded but window.google.ima missing"));
};
script.onerror = () => {
// Flag ad‑block detected
window.__vpAdBlocker = true;
reject(new Error("Failed to load IMA SDK"));
};
document.head.appendChild(script);
});
}
function isMobile() {
let check = false;
(function (a) {
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) ||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4)))
check = true;
})(navigator.userAgent || navigator.vendor || window.opera);
return check;
}
/**
* This service class is in charge of:
* - Initializing the video element for playback
* - Setting up HLS.js if needed
* - Handling playback controls and events
*/
class VideoService {
container;
callbacks;
videoElement = null;
hlsInstance = null;
playing = false;
videoConfig;
started = false;
constructor(container, videoConfig, callbacks) {
this.container = container;
this.videoConfig = videoConfig;
this.callbacks = callbacks;
}
/**
* Creates and initializes the video element with event listeners.
*/
initializeVideoElement() {
this.videoElement = this.container.querySelector(".vp-video");
if (this.videoElement) {
this.setupVideoListeners();
return this.videoElement;
}
const video = document.createElement("video");
video.classList.add("vp-video");
video.playsInline = true;
video.preload = "auto";
this.videoElement = video;
this.setupVideoListeners();
this.container.appendChild(video);
return video;
}
setupVideoListeners() {
const v = this.videoElement;
this.teardown.push(this.addListener(v, "play", () => this.handlePlay()), this.addListener(v, "pause", () => this.handlePause()), this.addListener(v, "timeupdate", () => this.handleTimeUpdate()), this.addListener(v, "ended", () => this.handleEnded()), this.addListener(v, "error", () => this.handleError(this.extractMediaError())), this.addListener(v, "click", (e) => this.handleClick(e)));
}
/**
* Converts the current MediaError on the video element into an Error.
*/
extractMediaError() {
const error = this.videoElement?.error;
if (!error) {
return new Error("Unknown video error");
}
switch (error.code) {
case MediaError.MEDIA_ERR_ABORTED:
return new Error("Video playback aborted");
case MediaError.MEDIA_ERR_NETWORK:
return new Error("Network error while loading video");
case MediaError.MEDIA_ERR_DECODE:
return new Error("Video decoding error");
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return new Error("Video format not supported");
default:
return new Error(`Unknown video error: ${error.message || error.code}`);
}
}
/**
* Loads a standard (non-HLS) video source.
*/
loadStandardVideo(url) {
this.videoElement.src = url;
this.videoElement.load();
}
/**
* Waits for video metadata to load.
*/
async waitForMetadata() {
if (this.videoElement.readyState > 0)
return;
return new Promise((resolve) => {
const video = this.videoElement;
const onLoaded = () => {
video.removeEventListener("loadedmetadata", onLoaded);
resolve();
};
video.addEventListener("loadedmetadata", onLoaded, { once: true });
});
}
async loadVideo(muted = true) {
this.log("Loading video:", this.videoConfig.url, "muted:", muted);
this.destroy(); // Clean up any previous instance
// Initialize or get existing video element
const video = this.initializeVideoElement();
// Apply settings
video.muted = muted;
video.volume = muted ? 0 : 1;
video.loop = this.videoConfig.loop || false;
// Notify listeners
this.callbacks.onAdRequested?.();
// Validate URL
const url = this.videoConfig.url;
if (!url) {
this.handleError(new Error("No video URL provided"));
return;
}
const isHLS = url.includes(".m3u8");
this.log(`URL: ${url}, Format: ${isHLS ? "HLS" : "Standard video"}`);
try {
if (isHLS) {
this.log("Setting up HLS playback");
await this.setupHLS(url);
}
else {
this.log("Setting up standard video playback");
this.loadStandardVideo(url);
}
// Ensure metadata loaded before playing
await this.waitForMetadata();
this.callbacks.onAdLoaded?.(); // For consistency
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.error("Video loading error:", message);
this.handleError(err instanceof Error ? err : new Error(message));
}
}
async setupHLS(url) {
try {
await loadHLSScript();
if (window.Hls && window.Hls.isSupported()) {
this.log("HLS.js is supported, creating instance");
this.hlsInstance = new window.Hls();
return new Promise((resolve, reject) => {
// Add more detailed error handling
this.hlsInstance.on(window.Hls.Events.ERROR, (_, data) => {
this.warn("HLS Error:", data);
// Only reject for fatal errors
if (data.fatal) {
const errorType = data.type;
const errorDetails = data.details;
const errorMessage = `HLS fatal error: ${errorType}, details: ${errorDetails}`;
this.error(errorMessage);
reject(new Error(errorMessage));
}
});
// Setup manifest parsing event
this.hlsInstance.on(window.Hls.Events.MANIFEST_PARSED, () => {
this.log("HLS manifest parsed successfully, stream ready");
resolve();
});
// Load source and attach media
if (this.videoElement) {
this.log("Loading HLS source:", url);
this.hlsInstance.loadSource(url);
this.hlsInstance.attachMedia(this.videoElement);
}
else {
reject(new Error("Video element not available"));
}
});
}
else if (this.videoElement &&
this.videoElement.canPlayType("application/vnd.apple.mpegurl")) {
// For Safari which has native HLS support
this.log("Using native HLS support");
this.videoElement.src = url;
}
else {
throw new Error("HLS is not supported in this browser and no native support available");
}
}
catch (err) {
this.error("HLS initialization error:", err);
throw new Error(`Failed to initialize HLS: ${err}`);
}
}
handlePlay() {
this.playing = true;
this.callbacks.onAdResume?.();
}
handlePause() {
this.playing = false;
this.callbacks.onAdPause?.();
}
handleTimeUpdate() {
if (this.videoElement) {
this.callbacks.onAdTimeUpdate?.(this.videoElement.currentTime);
}
}
handleEnded() {
// If loop is true, the video will restart automatically
// If not, we trigger the complete callback
if (!this.videoConfig.loop) {
this.callbacks.onAdComplete?.();
}
}
/**
* Handles click events on the video element to toggle play/pause.
*/
handleClick(e) {
e.preventDefault();
this.playing ? this.pause() : this.play();
}
handleError(err) {
this.error("Video playback error:", err);
this.callbacks.onAdError?.(err); // For consistency
}
async play() {
const video = this.videoElement;
if (!video)
return;
try {
// Ensure we have a source
if (!video.src && !this.hlsInstance?.media) {
throw new Error("No video source available");
}
// Wait for metadata if needed
if (video.readyState === 0) {
await this.waitForMetadata();
}
// Attempt playback
await video.play();
this.log("Video playback started successfully");
this.playing = true;
if (!this.started) {
this.callbacks.onAdStarted?.();
this.started = true;
}
}
catch (err) {
const msg = String(err);
this.error("Failed to play video:", msg);
// Handle autoplay restrictions by retrying muted
if (/(NotAllowedError|user gesture)/i.test(msg)) {
this.warn("Unmuted play blocked, retrying muted");
video.muted = true;
try {
await this.play();
this.log("Video playing muted due to autoplay restrictions");
this.playing = true;
}
catch (muteErr) {
this.error("Muted play attempt failed:", muteErr);
throw muteErr;
}
}
else {
throw err;
}
}
}
pause() {
if (this.videoElement) {
this.videoElement.pause();
this.playing = false;
}
}
setMuted(muted) {
if (this.videoElement) {
this.videoElement.muted = muted;
this.setVolume(muted ? 0 : 1);
}
}
setVolume(volume) {
if (this.videoElement) {
this.videoElement.volume = volume;
this.videoElement.muted = volume === 0;
}
}
skipAd() {
// Skip to end of video
if (this.videoElement) {
this.videoElement.currentTime = this.videoElement.duration;
this.callbacks.onAdSkipped?.();
}
}
get isPlaying() {
return this.playing;
}
get isMuted() {
return this.videoElement ? this.videoElement.muted : false;
}
get volume() {
return this.videoElement ? this.videoElement.volume : 0;
}
get currentTime() {
return this.videoElement ? this.videoElement.currentTime : 0;
}
get duration() {
return this.videoElement ? this.videoElement.duration || 0 : 0;
}
log(...args) {
if (this.videoConfig?.debug) {
console.log("[VideoService]", ...args);
}
}
warn(...args) {
console.warn("[VideoService]", ...args);
}
error(...args) {
console.error("[VideoService]", ...args);
}
addListener = (el, type, fn) => {
el.addEventListener(type, fn);
return () => el.removeEventListener(type, fn);
};
teardown = []; // collect cleaners
destroy() {
if (this.hlsInstance) {
this.hlsInstance.destroy();
this.hlsInstance = null;
}
if (this.videoElement) {
this.videoElement.removeAttribute("src");
this.videoElement.load();
this.videoElement.remove();
this.videoElement = null;
}
this.teardown.forEach((off) => off());
this.teardown.length = 0;
}
}
const defaultConfig = {
ads: {
adBreaks: [],
adCycleDelayMs: 3000,
adRetryLimit: 2,
adCycleRestartMs: -1,
},
video: {
url: "",
loop: true,
debug: false,
},
config: {
skin: {
icons: {
default: "#fff",
hover: "#ededed",
},
slider: {
rail: "#ededed66",
progress: "#fec309",
dragger: "#fec309",
},
},
controls: {
play: true,
mute: true,
volume: true,
},
hideWhenNoAd: true,
viewableThreshold: 0.5,
autostartOnViewable: {
state: true,
},
autopauseOnViewable: {
state: false,
},
startMuted: true,
responsive: false,
size: {
width: 640,
height: 360,
},
debug: false,
},
};
const template = () => `
<video class="vp-video" playsinline webkit-playsinline x5-playsinline x5-video-player-autoplay></video>
<div class="vp-ad-container"></div>
<div class="vp-loading"><div class="vp-loader"><svg class="circular" viewBox="25 25 50 50"><circle class="path" cx="50" cy="50" r="20" fill="none" stroke-width="3" stroke-miterlimit="10"/></svg></div></div>
<div class="vp-ui-backdrop"></div>
<div class="vp-player-ui">
<div class="vp-controls">
<button class="vp-btn vp-play hidden"></button>
<div class="vp-volume-container">
<button class="vp-btn vp-mute hidden"></button>
<input class="vp-slider vp-volume" type="range" min="0" max="1" step="0.02" value="0" />
</div>
<div class="vp-progress-time">
<span class="vp-time">00:00</span>
</div>
<button class="vp-btn vp-skip hidden"></button>
<div class="vp-progress">
<div class="vp-progress-bar"></div>
</div>
</div>
</div>
`;
function wireControls(player) {
const { playButton, muteButton, skipButton, volumeButton } = player;
playButton?.addEventListener("click", () => {
const playing = player.isPlaying;
if (playing) {
player.pause();
}
else {
player.play();
}
});
muteButton?.addEventListener("click", () => {
const muted = player.isMuted;
if (muted) {
player.unmute();
}
else {
player.mute();
}
});
volumeButton?.addEventListener("input", (e) => {
const value = parseFloat(e.target.value);
player.setVolume(value);
updateGradient(e.target);
});
skipButton?.addEventListener("click", () => {
player.skipAd();
});
}
function updateGradient(element) {
if (!element)
return;
const value = parseFloat(element.value);
const min = parseFloat(element.min);
const max = parseFloat(element.max);
const val = ((value - min) / (max - min)) * 100;
element.style.backgroundImage = `linear-gradient(to right,
var(--player-progress-color) 0%,
var(--player-progress-color) ${val}%,
var(--player-rail-color) ${val}%,
var(--player-rail-color) 100%)`;
}
function hideVolumeContainer(playerContainer) {
if (!playerContainer)
return;
const volumeContainer = playerContainer.querySelector(".vp-volume-container");
volumeContainer.style.display = "none";
}
function setSkin(container, skin) {
if (!skin || !container)
return;
const root = container;
const { icons, slider } = skin;
if (icons) {
if (icons.default)
root.style.setProperty("--player-icon-color", icons.default);
if (icons.hover)
root.style.setProperty("--player-icon-hover-color", icons.hover);
}
if (slider) {
if (slider.rail)
root.style.setProperty("--player-rail-color", slider.rail);
if (slider.progress)
root.style.setProperty("--player-progress-color", slider.progress);
if (slider.dragger)
root.style.setProperty("--player-dragger-color", slider.dragger);
}
}
const LOADING_DELAY = 750; // ms
class VpOutstreamPlayer {
container;
fullConfig;
adsObject;
videoObject;
config;
viewTracker = null;
vastService = null;
videoService = null;
currentAdIndex = 0;
retryCount = 0;
adCycleTimeout = null;
hasUserInteraction = false;
adBlockerDetected = false;
isVideoMode = false;
playerUi;
controls;
playButton;
muteButton;
skipButton;
volumeButton;
progressBarEl;
timeEl;
isLoading = false;
adLoaded = false;
isMobile = isMobile();
// Track original and added CSS classes on the container
originalClasses;
addedClasses = 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) {
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 */
addContainerClass(className) {
this.container.classList.add(className);
if (!this.originalClasses.includes(className)) {
this.addedClasses.add(className);
}
}
/** Toggle a class and track addition if not original */
toggleContainerClass(className, isActive) {
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
*/
async setup(config) {
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();
}
setInitialContainerState() {
this.addContainerClass("vp-outstream-player");
this.hidePlayer(); // Hide the container initially
if (this.isMobile) {
this.addContainerClass("vp-mobile");
}
}
setTemplate() {
this.container.innerHTML = template();
}
registerControls() {
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[`${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();
}
registerProgressElements() {
this.progressBarEl = this.container.querySelector(".vp-progress-bar") ?? undefined;
this.timeEl = this.container.querySelector(".vp-time") ?? undefined;
// Render initial values
this.updateProgress();
}
updateProgress() {
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);
}
updateVolume() {
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() {
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() {
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) => {
if (!inView && autopauseOnViewable) {
this.pause();
}
else if (inView && autostartOnViewable) {
this.play();
}
};
this.viewTracker = new ViewabilityTracker(this.container, onChangeHandler);
}
async initVideoService() {
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() {
// 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 = () => { };
/**
* Starts the ad cycle from the current index.
*/
async start() {
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();
}
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,
};
}
playAdBreak(index) {
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.
*/
scheduleNextAd() {
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.
*/
handleAdRequested() {
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.
*/
onAdLoaded() {
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
*/
handleAdStarted() {
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
*/
handleAdTimeUpdate(currentTime) {
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.
*/
handleAdSkipped() {
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.
*/
handleAdComplete() {
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.
*/
handleAdError(err) {
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);
}
handleAdResume() {
this.log("Ad resumed");
this.updateStateClasses();
this.fullConfig.onAdResume?.();
}
handleAdPause() {
this.log("Ad paused");
this.updateStateClasses();
this.fullConfig.onAdPause?.();
}
hideUi() {
if (!this.playerUi)
return;
this.log("Hiding player UI");
this.playerUi.classList.add("hidden");
}
showUi() {
if (!this.playerUi)
return;
this.resetUi();
this.log("Showing player UI");
this.playerUi.classList.remove("hidden");
}
resetUi() {
if (!this.playerUi)
return;
this.updateProgress();
this.updateVolume();
this.updateStateClasses();
}
hidePlayer() {
this.container.style.display = "none";
this.log("Player hidden");
}
showPlayer() {
this.container.style.display = "block";
this.log("Player shown");
}
showLoading() {
if (!this.playerUi)
return;
this.isLoading = true;
this.updateStateClasses();
}
hideLoading() {
if (!this.playerUi)
return;
this.isLoading = false;
this.updateStateClasses();
}
log(...args) {
if (this.config?.debug) {
console.log("[VpOutstreamPlayer]", ...args);
}
}
warn(...args) {
console.warn("[VpOutstreamPlayer]", ...args);
}
error(...args) {
console.error("[VpOutstreamPlayer]", ...args);
}
/**
* plays the ad if applicable.
*/
play() {
if (this.isVideoMode) {
this.videoService?.play();
}
else {
this.vastService?.resumeAd();
}
this.updateStateClasses();
}
/**
* Pauses the ad if applicable.
*/
pause() {
if (this.isVideoMode) {
this.videoService?.pause();
}
else {
this.vastService?.pauseAd();
}
this.updateStateClasses();
}
/**
* Unmutes the ad playback.
*/
unmute() {
if (this.isVideoMode) {
this.videoService?.setMuted(false);
}
else {
this.vastService?.setMuted(false);
}
this.updateVolume();
this.updateStateClasses();
}
/**
* Mutes the ad playback.
*/
mute() {
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.
*/
setVolume(volume) {
if (this.isVideoMode) {
this.videoService?.setVolume(volume);
}
else {
this.vastService?.setVolume(volume);
}
this.updateVolume();
this.updateStateClasses();
}
skipAd() {
if (this.isVideoMode) {
this.videoService?.skipAd();
}
else {
this.vastService?.skipAd();
}
}
get isPlaying() {
return this.isVideoMode
? this.videoService?.isPlaying ?? false
: this.vastService?.isPlaying ?? false;
}
get isMuted() {
return this.isVideoMode
? this.videoService?.isMuted ?? false
: this.vastService?.isMuted ?? false;
}
get isViewable() {
return this.viewTracker?.isInViewState ?? false;
}
get volume() {
return this.isVideoMode ? this.videoService?.volume ?? 0 : this.vastService?.volume ?? 0;
}
get currentTime() {
return this.isVideoMode
? this.videoService?.currentTime ?? 0
: this.vastService?.currentTime ?? 0;
}
get duration() {
return this.isVideoMode ? this.videoService?.duration ?? 0 : this.vastService?.duration ?? 0;
}
get adData() {
return this.vastService?.adData;
}
getState() {
return {
isMuted: this.isMuted,
isPlaying: this.isPlaying,
volume: this.volume,
duration: this.duration,
currentTime: this.currentTime,
loading: this.isLoading,
isVideoMode: this.isVideoMode,
};
}
updateStateClasses() {
if (!this.container)
return;
const stateMap = {
"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);
});
}
callAfter(callback, delay) {
const timer = setTimeout(() => {
callback();
clearTimeout(timer);
}, delay);
}
/**
* Destroys the player, cleaning up observers and ad managers.
*/
destroy() {
this.viewTracker?.destroy();
this.vastService?.destroy();
this.videoService?.destroy();
if (this.adCycleTimeout) {
clear