UNPKG

vp-outstream-player

Version:

Outstream video player with Google IMA integration

1,454 lines (1,447 loc) 90 kB
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