stormcloud-video-player
Version:
Ad-first HLS video player with SCTE-35 support and Google IMA integration for precise ad break alignment
1,486 lines (1,482 loc) • 144 kB
JavaScript
// src/ui/StormcloudVideoPlayer.tsx
import React, { useEffect, useRef, useMemo } from "react";
// src/player/StormcloudVideoPlayer.ts
import Hls from "hls.js";
// src/sdk/ima.ts
function createImaController(video, options) {
let adPlaying = false;
let originalMutedState = false;
const listeners = /* @__PURE__ */ new Map();
function emit(event, payload) {
const set = listeners.get(event);
if (!set) return;
for (const fn of Array.from(set)) {
try {
fn(payload);
} catch {
}
}
}
function ensureImaLoaded() {
try {
const frameEl = window.frameElement;
const sandboxAttr = frameEl?.getAttribute?.("sandbox") || "";
if (sandboxAttr) {
const tokens = new Set(
sandboxAttr.split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0)
);
const allowsScripts = tokens.has("allow-scripts");
if (!allowsScripts) {
console.error(
"StormcloudVideoPlayer: The host page is inside a sandboxed iframe without 'allow-scripts'. Google IMA cannot run ads within sandboxed frames. Remove the sandbox attribute or include 'allow-scripts allow-same-origin'."
);
}
}
} catch {
}
if (typeof window !== "undefined" && window.google?.ima)
return Promise.resolve();
const existing = document.querySelector(
'script[data-ima="true"]'
);
if (existing) {
return new Promise(
(resolve) => existing.addEventListener("load", () => resolve())
);
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://imasdk.googleapis.com/js/sdkloader/ima3.js";
script.async = true;
script.defer = true;
script.setAttribute("data-ima", "true");
script.onload = () => resolve();
script.onerror = () => reject(new Error("IMA SDK load failed"));
document.head.appendChild(script);
});
}
let adsManager;
let adsLoader;
let adDisplayContainer;
let adContainerEl;
let lastAdTagUrl;
let retryAttempts = 0;
const maxRetries = 2;
const backoffBaseMs = 500;
let adsLoadedPromise;
let adsLoadedResolve;
let adsLoadedReject;
function makeAdsRequest(google, vastTagUrl) {
const adsRequest = new google.ima.AdsRequest();
adsRequest.adTagUrl = vastTagUrl;
adsLoader.requestAds(adsRequest);
}
return {
initialize() {
ensureImaLoaded().then(() => {
const google = window.google;
if (!adDisplayContainer) {
const container = document.createElement("div");
container.style.position = "absolute";
container.style.left = "0";
container.style.top = "0";
container.style.right = "0";
container.style.bottom = "0";
container.style.display = "flex";
container.style.alignItems = "center";
container.style.justifyContent = "center";
container.style.pointerEvents = "none";
container.style.zIndex = "2";
video.parentElement?.appendChild(container);
adContainerEl = container;
adDisplayContainer = new google.ima.AdDisplayContainer(
container,
video
);
try {
adDisplayContainer.initialize?.();
} catch {
}
}
}).catch(() => {
});
},
async requestAds(vastTagUrl) {
console.log("[IMA] Requesting ads:", vastTagUrl);
adsLoadedPromise = new Promise((resolve, reject) => {
adsLoadedResolve = resolve;
adsLoadedReject = reject;
setTimeout(() => {
if (adsLoadedReject) {
adsLoadedReject(new Error("Ad request timeout"));
adsLoadedReject = void 0;
adsLoadedResolve = void 0;
}
}, 1e4);
});
try {
await ensureImaLoaded();
const google = window.google;
lastAdTagUrl = vastTagUrl;
retryAttempts = 0;
if (!adDisplayContainer) {
console.log("[IMA] Creating ad display container");
const container = document.createElement("div");
container.style.position = "absolute";
container.style.left = "0";
container.style.top = "0";
container.style.right = "0";
container.style.bottom = "0";
container.style.display = "flex";
container.style.alignItems = "center";
container.style.justifyContent = "center";
container.style.pointerEvents = "none";
container.style.zIndex = "2";
if (!video.parentElement) {
throw new Error("Video element has no parent for ad container");
}
video.parentElement.appendChild(container);
adContainerEl = container;
adDisplayContainer = new google.ima.AdDisplayContainer(
container,
video
);
try {
adDisplayContainer.initialize();
console.log("[IMA] Ad display container initialized");
} catch (error) {
console.warn(
"[IMA] Failed to initialize ad display container:",
error
);
}
}
if (!adsLoader) {
console.log("[IMA] Creating ads loader");
const adsLoaderCls = new google.ima.AdsLoader(adDisplayContainer);
adsLoader = adsLoaderCls;
adsLoader.addEventListener(
google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,
(evt) => {
console.log("[IMA] Ads manager loaded");
try {
adsManager = evt.getAdsManager(video);
const AdEvent = google.ima.AdEvent.Type;
const AdErrorEvent = google.ima.AdErrorEvent.Type;
adsManager.addEventListener(
AdErrorEvent.AD_ERROR,
(errorEvent) => {
console.error("[IMA] Ad error:", errorEvent.getError());
try {
adsManager?.destroy?.();
} catch {
}
adPlaying = false;
video.muted = originalMutedState;
if (adContainerEl)
adContainerEl.style.pointerEvents = "none";
if (adsLoadedReject) {
adsLoadedReject(new Error("Ad playback error"));
adsLoadedReject = void 0;
adsLoadedResolve = void 0;
}
if (lastAdTagUrl && retryAttempts < maxRetries) {
const delay = backoffBaseMs * Math.pow(2, retryAttempts++);
console.log(
`[IMA] Retrying ad request in ${delay}ms (attempt ${retryAttempts})`
);
window.setTimeout(() => {
try {
makeAdsRequest(google, lastAdTagUrl);
} catch {
}
}, delay);
} else {
console.log(
"[IMA] Max retries reached, emitting ad_error"
);
emit("ad_error");
if (!options?.continueLiveStreamDuringAds) {
video.play().catch(() => {
});
}
}
}
);
adsManager.addEventListener(
AdEvent.CONTENT_PAUSE_REQUESTED,
() => {
console.log("[IMA] Content pause requested");
originalMutedState = video.muted;
video.muted = true;
if (!options?.continueLiveStreamDuringAds) {
video.pause();
console.log("[IMA] Video paused (VOD mode)");
} else {
console.log(
"[IMA] Video continues playing but muted (Live mode)"
);
}
adPlaying = true;
if (adContainerEl)
adContainerEl.style.pointerEvents = "auto";
emit("content_pause");
}
);
adsManager.addEventListener(
AdEvent.CONTENT_RESUME_REQUESTED,
() => {
console.log("[IMA] Content resume requested");
adPlaying = false;
video.muted = originalMutedState;
if (adContainerEl)
adContainerEl.style.pointerEvents = "none";
if (!options?.continueLiveStreamDuringAds) {
video.play().catch(() => {
});
console.log("[IMA] Video resumed (VOD mode)");
} else {
console.log(
"[IMA] Video unmuted (Live mode - was never paused)"
);
}
emit("content_resume");
}
);
adsManager.addEventListener(AdEvent.ALL_ADS_COMPLETED, () => {
console.log("[IMA] All ads completed");
adPlaying = false;
video.muted = originalMutedState;
if (adContainerEl) adContainerEl.style.pointerEvents = "none";
if (!options?.continueLiveStreamDuringAds) {
video.play().catch(() => {
});
console.log(
"[IMA] Video resumed after all ads completed (VOD mode)"
);
} else {
console.log(
"[IMA] Video unmuted after all ads completed (Live mode)"
);
}
emit("all_ads_completed");
});
console.log("[IMA] Ads manager event listeners attached");
if (adsLoadedResolve) {
adsLoadedResolve();
adsLoadedResolve = void 0;
adsLoadedReject = void 0;
}
} catch (e) {
console.error("[IMA] Error setting up ads manager:", e);
adPlaying = false;
video.muted = originalMutedState;
if (adContainerEl) adContainerEl.style.pointerEvents = "none";
if (!options?.continueLiveStreamDuringAds) {
video.play().catch(() => {
});
}
if (adsLoadedReject) {
adsLoadedReject(new Error("Failed to setup ads manager"));
adsLoadedReject = void 0;
adsLoadedResolve = void 0;
}
emit("ad_error");
}
},
false
);
adsLoader.addEventListener(
google.ima.AdErrorEvent.Type.AD_ERROR,
(adErrorEvent) => {
console.error("[IMA] Ads loader error:", adErrorEvent.getError());
if (adsLoadedReject) {
adsLoadedReject(new Error("Ads loader error"));
adsLoadedReject = void 0;
adsLoadedResolve = void 0;
}
emit("ad_error");
},
false
);
}
console.log("[IMA] Making ads request");
makeAdsRequest(google, vastTagUrl);
return adsLoadedPromise;
} catch (error) {
console.error("[IMA] Failed to request ads:", error);
if (adsLoadedReject) {
adsLoadedReject(error);
adsLoadedReject = void 0;
adsLoadedResolve = void 0;
}
return Promise.reject(error);
}
},
async play() {
if (!window.google?.ima || !adDisplayContainer) {
console.warn(
"[IMA] Cannot play ad: IMA SDK or ad container not available"
);
return Promise.reject(new Error("IMA SDK not available"));
}
if (!adsManager) {
console.warn("[IMA] Cannot play ad: No ads manager available");
return Promise.reject(new Error("No ads manager"));
}
try {
const width = video.clientWidth || 640;
const height = video.clientHeight || 360;
console.log(`[IMA] Initializing ads manager (${width}x${height})`);
adsManager.init(width, height, window.google.ima.ViewMode.NORMAL);
if (!options?.continueLiveStreamDuringAds) {
console.log("[IMA] Pausing video for ad playback (VOD mode)");
video.pause();
} else {
console.log(
"[IMA] Keeping video playing but muted for ad playback (Live mode)"
);
}
adPlaying = true;
console.log("[IMA] Starting ad playback");
adsManager.start();
return Promise.resolve();
} catch (error) {
console.error("[IMA] Error starting ad playback:", error);
adPlaying = false;
if (!options?.continueLiveStreamDuringAds) {
video.play().catch(() => {
});
}
return Promise.reject(error);
}
},
async stop() {
adPlaying = false;
video.muted = originalMutedState;
try {
adsManager?.stop?.();
} catch {
}
if (!options?.continueLiveStreamDuringAds) {
video.play().catch(() => {
});
console.log("[IMA] Video resumed after stop (VOD mode)");
} else {
console.log("[IMA] Video unmuted after stop (Live mode)");
}
},
destroy() {
try {
adsManager?.destroy?.();
} catch {
}
adPlaying = false;
video.muted = originalMutedState;
try {
adsLoader?.destroy?.();
} catch {
}
if (adContainerEl?.parentElement) {
adContainerEl.parentElement.removeChild(adContainerEl);
}
},
isAdPlaying() {
return adPlaying;
},
resize(width, height) {
if (!adsManager || !window.google?.ima) {
console.warn(
"[IMA] Cannot resize: No ads manager or IMA SDK available"
);
return;
}
try {
console.log(`[IMA] Resizing ads manager to ${width}x${height}`);
adsManager.resize(width, height, window.google.ima.ViewMode.NORMAL);
} catch (error) {
console.warn("[IMA] Error resizing ads manager:", error);
}
},
on(event, listener) {
if (!listeners.has(event)) listeners.set(event, /* @__PURE__ */ new Set());
listeners.get(event).add(listener);
},
off(event, listener) {
listeners.get(event)?.delete(listener);
},
updateOriginalMutedState(muted) {
originalMutedState = muted;
},
getOriginalMutedState() {
return originalMutedState;
},
setAdVolume(volume) {
if (adsManager && adPlaying) {
try {
adsManager.setVolume(Math.max(0, Math.min(1, volume)));
} catch (error) {
console.warn("[IMA] Failed to set ad volume:", error);
}
}
},
getAdVolume() {
if (adsManager && adPlaying) {
try {
return adsManager.getVolume();
} catch (error) {
console.warn("[IMA] Failed to get ad volume:", error);
return 1;
}
}
return 1;
}
};
}
// src/utils/tracking.ts
function getClientInfo() {
const ua = navigator.userAgent;
const platform = navigator.platform;
const vendor = navigator.vendor || "";
const maxTouchPoints = navigator.maxTouchPoints || 0;
const memory = navigator.deviceMemory || null;
const hardwareConcurrency = navigator.hardwareConcurrency || 1;
const screenInfo = {
width: screen?.width,
height: screen?.height,
availWidth: screen?.availWidth,
availHeight: screen?.availHeight,
orientation: screen?.orientation?.type || "",
pixelDepth: screen?.pixelDepth
};
let deviceType = "desktop";
let brand = "Unknown";
let os = "Unknown";
let model = "";
let isSmartTV = false;
let isAndroid = false;
let isWebView = false;
let isWebApp = false;
if (ua.includes("Web0S")) {
brand = "LG";
os = "webOS";
isSmartTV = true;
deviceType = "tv";
const webosMatch = ua.match(/Web0S\/([^\s]+)/);
model = webosMatch ? `webOS ${webosMatch[1]}` : "webOS TV";
} else if (ua.includes("Tizen")) {
brand = "Samsung";
os = "Tizen";
isSmartTV = true;
deviceType = "tv";
const tizenMatch = ua.match(/Tizen\/([^\s]+)/);
const tvMatch = ua.match(/(?:Smart-TV|SMART-TV|TV)/i) ? "Smart TV" : "";
model = tizenMatch ? `Tizen ${tizenMatch[1]} ${tvMatch}`.trim() : "Tizen TV";
} else if (ua.includes("Philips")) {
brand = "Philips";
os = "Saphi";
isSmartTV = true;
deviceType = "tv";
} else if (ua.includes("Sharp") || ua.includes("AQUOS")) {
brand = "Sharp";
os = "Android TV";
isSmartTV = true;
deviceType = "tv";
} else if (ua.includes("Android") && (ua.includes("Sony") || vendor.includes("Sony"))) {
brand = "Sony";
os = "Android TV";
isSmartTV = true;
deviceType = "tv";
} else if (ua.includes("Android") && (ua.includes("NetCast") || ua.includes("LG"))) {
brand = "LG";
os = "Android TV";
isSmartTV = true;
deviceType = "tv";
} else if (ua.includes(" Roku") || ua.includes("Roku/")) {
brand = "Roku";
os = "Roku OS";
isSmartTV = true;
deviceType = "tv";
} else if (ua.includes("AppleTV")) {
brand = "Apple";
os = "tvOS";
isSmartTV = true;
deviceType = "tv";
}
if (ua.includes("Android")) {
isAndroid = true;
os = "Android";
deviceType = /Mobile/.test(ua) ? "mobile" : "tablet";
if (ua.includes("Android") && (maxTouchPoints === 0 || ua.includes("Google TV") || ua.includes("XiaoMi"))) {
deviceType = "tv";
isSmartTV = true;
brand = brand === "Unknown" ? "Android TV" : brand;
}
const androidModelMatch = ua.match(/\(([^)]*Android[^)]*)\)/);
if (androidModelMatch && androidModelMatch[1]) {
model = androidModelMatch[1];
}
}
if (/iPad|iPhone|iPod/.test(ua)) {
os = "iOS";
deviceType = "mobile";
brand = "Apple";
if (navigator.maxTouchPoints > 1 && /iPad/.test(ua)) {
deviceType = "tablet";
}
}
if (!isAndroid && !isSmartTV && !/Mobile/.test(ua)) {
if (ua.includes("Windows")) {
os = "Windows";
deviceType = "desktop";
} else if (ua.includes("Mac") && !/iPhone/.test(ua)) {
os = "macOS";
deviceType = "desktop";
if (maxTouchPoints > 1) deviceType = "tablet";
} else if (ua.includes("Linux")) {
os = "Linux";
deviceType = "desktop";
}
}
if (brand === "Unknown") {
if (vendor.includes("Google") || ua.includes("Chrome")) brand = "Google";
if (vendor.includes("Apple")) brand = "Apple";
if (vendor.includes("Samsung") || ua.includes("SM-")) brand = "Samsung";
}
isWebView = /wv|WebView|Linux; U;/.test(ua);
if (window?.outerHeight === 0 && window?.outerWidth === 0) {
isWebView = true;
}
isWebApp = window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true || window.screen?.orientation?.angle !== void 0;
return {
brand,
os,
model: model || ua.substring(0, 50) + "...",
deviceType,
isSmartTV,
isAndroid,
isWebView,
isWebApp,
domain: window.location.hostname,
origin: window.location.origin,
path: window.location.pathname,
userAgent: ua,
vendor,
platform,
screen: screenInfo,
hardwareConcurrency,
deviceMemory: memory,
maxTouchPoints,
language: navigator.language,
languages: navigator.languages?.join(",") || "",
cookieEnabled: navigator.cookieEnabled,
doNotTrack: navigator.doNotTrack || "",
referrer: document.referrer,
visibilityState: document.visibilityState
};
}
async function getBrowserID(clientInfo) {
const fingerprintString = JSON.stringify(clientInfo);
const hashBuffer = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(fingerprintString)
);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
return hashHex;
}
async function sendInitialTracking(licenseKey) {
try {
const clientInfo = getClientInfo();
const browserId = await getBrowserID(clientInfo);
const trackingData = {
browserId,
...clientInfo
};
const headers = {
"Content-Type": "application/json"
};
if (licenseKey) {
headers["Authorization"] = `Bearer ${licenseKey}`;
}
const response = await fetch(
"https://adstorm.co/api-adstorm-dev/adstorm/player-tracking/track",
{
method: "POST",
headers,
body: JSON.stringify(trackingData)
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
await response.json();
} catch (error) {
console.error(
"[StormcloudVideoPlayer] Error sending initial tracking data:",
error
);
}
}
async function sendHeartbeat(licenseKey) {
try {
const clientInfo = getClientInfo();
const browserId = await getBrowserID(clientInfo);
const heartbeatData = {
browserId,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
const headers = {
"Content-Type": "application/json"
};
if (licenseKey) {
headers["Authorization"] = `Bearer ${licenseKey}`;
}
const response = await fetch(
"https://adstorm.co/api-adstorm-dev/adstorm/player-tracking/heartbeat",
{
method: "POST",
headers,
body: JSON.stringify(heartbeatData)
}
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
await response.json();
} catch (error) {
console.error("[StormcloudVideoPlayer] Error sending heartbeat:", error);
}
}
// src/player/StormcloudVideoPlayer.ts
var StormcloudVideoPlayer = class {
constructor(config) {
this.attached = false;
this.inAdBreak = false;
this.ptsDriftEmaMs = 0;
this.adPodQueue = [];
this.lastHeartbeatTime = 0;
this.currentAdIndex = 0;
this.totalAdsInBreak = 0;
this.showAds = false;
this.isLiveStream = false;
this.config = config;
this.video = config.videoElement;
this.ima = createImaController(this.video, {
continueLiveStreamDuringAds: false
});
}
async load() {
if (!this.attached) {
this.attach();
}
try {
await this.fetchAdConfiguration();
} catch (error) {
if (this.config.debugAdTiming) {
console.warn(
"[StormcloudVideoPlayer] Failed to fetch ad configuration:",
error
);
}
}
this.initializeTracking();
if (this.shouldUseNativeHls()) {
this.video.src = this.config.src;
this.isLiveStream = this.config.lowLatencyMode ?? false;
if (this.config.debugAdTiming) {
console.log(
"[StormcloudVideoPlayer] allowNativeHls: true - VOD mode detected:",
{
isLive: this.isLiveStream,
allowNativeHls: this.config.allowNativeHls,
adBehavior: "vod (main video pauses during ads)"
}
);
}
this.ima.destroy();
this.ima = createImaController(this.video, {
continueLiveStreamDuringAds: false
});
this.ima.initialize();
if (this.config.autoplay) {
await this.video.play().catch(() => {
});
}
return;
}
this.hls = new Hls({
enableWorker: true,
backBufferLength: 30,
liveDurationInfinity: true,
lowLatencyMode: !!this.config.lowLatencyMode,
maxLiveSyncPlaybackRate: this.config.lowLatencyMode ? 1.5 : 1,
...this.config.lowLatencyMode ? { liveSyncDuration: 2 } : {}
});
this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
this.hls?.loadSource(this.config.src);
});
this.hls.on(Hls.Events.MANIFEST_PARSED, async (_, data) => {
this.isLiveStream = this.hls?.levels?.some(
(level) => level?.details?.live === true || level?.details?.type === "LIVE"
) ?? false;
if (this.config.debugAdTiming) {
const adBehavior = this.shouldContinueLiveStreamDuringAds() ? "live (main video continues muted during ads)" : "vod (main video pauses during ads)";
console.log("[StormcloudVideoPlayer] Stream type detected:", {
isLive: this.isLiveStream,
allowNativeHls: this.config.allowNativeHls,
adBehavior
});
}
this.ima.destroy();
this.ima = createImaController(this.video, {
continueLiveStreamDuringAds: this.shouldContinueLiveStreamDuringAds()
});
this.ima.initialize();
if (this.config.autoplay) {
await this.video.play().catch(() => {
});
}
});
this.hls.on(Hls.Events.FRAG_PARSING_METADATA, (_evt, data) => {
const id3Tags = (data?.samples || []).map((s) => ({
key: "ID3",
value: s?.data,
ptsSeconds: s?.pts
}));
id3Tags.forEach((tag) => this.onId3Tag(tag));
});
this.hls.on(Hls.Events.FRAG_CHANGED, (_evt, data) => {
const frag = data?.frag;
const tagList = frag?.tagList;
if (!Array.isArray(tagList)) return;
for (const entry of tagList) {
let tag = "";
let value = "";
if (Array.isArray(entry)) {
tag = String(entry[0] ?? "");
value = String(entry[1] ?? "");
} else if (typeof entry === "string") {
const idx = entry.indexOf(":");
if (idx >= 0) {
tag = entry.substring(0, idx);
value = entry.substring(idx + 1);
} else {
tag = entry;
value = "";
}
}
if (!tag) continue;
if (tag.includes("EXT-X-CUE-OUT")) {
const durationSeconds = this.parseCueOutDuration(value);
const marker = {
type: "start",
...durationSeconds !== void 0 ? { durationSeconds } : {},
raw: { tag, value }
};
this.onScte35Marker(marker);
} else if (tag.includes("EXT-X-CUE-OUT-CONT")) {
const prog = this.parseCueOutCont(value);
const marker = {
type: "progress",
...prog?.duration !== void 0 ? { durationSeconds: prog.duration } : {},
...prog?.elapsed !== void 0 ? { ptsSeconds: prog.elapsed } : {},
raw: { tag, value }
};
this.onScte35Marker(marker);
} else if (tag.includes("EXT-X-CUE-IN")) {
this.onScte35Marker({ type: "end", raw: { tag, value } });
} else if (tag.includes("EXT-X-DATERANGE")) {
const attrs = this.parseAttributeList(value);
const hasScteOut = "SCTE35-OUT" in attrs || attrs["SCTE35-OUT"] !== void 0;
const hasScteIn = "SCTE35-IN" in attrs || attrs["SCTE35-IN"] !== void 0;
const klass = String(attrs["CLASS"] ?? "");
const duration = this.toNumber(attrs["DURATION"]);
if (hasScteOut || /com\.apple\.hls\.cue/i.test(klass)) {
const marker = {
type: "start",
...duration !== void 0 ? { durationSeconds: duration } : {},
raw: { tag, value, attrs }
};
this.onScte35Marker(marker);
}
if (hasScteIn) {
this.onScte35Marker({ type: "end", raw: { tag, value, attrs } });
}
}
}
});
this.hls.on(Hls.Events.ERROR, (_evt, data) => {
if (data?.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
this.hls?.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
this.hls?.recoverMediaError();
break;
default:
this.destroy();
break;
}
}
});
this.hls.attachMedia(this.video);
}
attach() {
if (this.attached) return;
this.attached = true;
this.video.autoplay = !!this.config.autoplay;
this.video.muted = !!this.config.muted;
this.ima.initialize();
this.ima.on("all_ads_completed", () => {
if (!this.inAdBreak) return;
const remaining = this.getRemainingAdMs();
if (remaining > 500 && this.adPodQueue.length > 0) {
const next = this.adPodQueue.shift();
this.currentAdIndex++;
this.playSingleAd(next).catch(() => {
});
} else {
this.currentAdIndex = 0;
this.totalAdsInBreak = 0;
this.showAds = false;
}
});
this.ima.on("ad_error", () => {
if (this.config.debugAdTiming) {
console.log("[StormcloudVideoPlayer] IMA ad_error event received");
}
if (!this.inAdBreak) return;
const remaining = this.getRemainingAdMs();
if (remaining > 500 && this.adPodQueue.length > 0) {
const next = this.adPodQueue.shift();
this.currentAdIndex++;
this.playSingleAd(next).catch(() => {
});
} else {
this.handleAdFailure();
}
});
this.ima.on("content_pause", () => {
if (this.config.debugAdTiming) {
console.log("[StormcloudVideoPlayer] IMA content_pause event received");
}
this.clearAdFailsafeTimer();
});
this.ima.on("content_resume", () => {
if (this.config.debugAdTiming) {
console.log(
"[StormcloudVideoPlayer] IMA content_resume event received"
);
}
this.clearAdFailsafeTimer();
});
this.video.addEventListener("timeupdate", () => {
this.onTimeUpdate(this.video.currentTime);
});
}
shouldUseNativeHls() {
const streamType = this.getStreamType();
if (streamType === "other") {
return true;
}
const canNative = this.video.canPlayType("application/vnd.apple.mpegURL");
return !!(this.config.allowNativeHls && canNative);
}
onId3Tag(tag) {
if (typeof tag.ptsSeconds === "number") {
this.updatePtsDrift(tag.ptsSeconds);
}
const marker = this.parseScte35FromId3(tag);
if (marker) {
this.onScte35Marker(marker);
}
}
parseScte35FromId3(tag) {
const text = this.decodeId3ValueToText(tag.value);
if (!text) return void 0;
const cueOutMatch = text.match(/EXT-X-CUE-OUT(?::([^\r\n]*))?/i) || text.match(/CUE-OUT(?::([^\r\n]*))?/i);
if (cueOutMatch) {
const arg = (cueOutMatch[1] ?? "").trim();
const dur = this.parseCueOutDuration(arg);
const marker = {
type: "start",
...tag.ptsSeconds !== void 0 ? { ptsSeconds: tag.ptsSeconds } : {},
...dur !== void 0 ? { durationSeconds: dur } : {},
raw: { id3: text }
};
return marker;
}
const cueOutContMatch = text.match(/EXT-X-CUE-OUT-CONT:([^\r\n]*)/i);
if (cueOutContMatch) {
const arg = (cueOutContMatch[1] ?? "").trim();
const cont = this.parseCueOutCont(arg);
const marker = {
type: "progress",
...tag.ptsSeconds !== void 0 ? { ptsSeconds: tag.ptsSeconds } : {},
...cont?.duration !== void 0 ? { durationSeconds: cont.duration } : {},
raw: { id3: text }
};
return marker;
}
const cueInMatch = text.match(/EXT-X-CUE-IN\b/i) || text.match(/CUE-IN\b/i);
if (cueInMatch) {
const marker = {
type: "end",
...tag.ptsSeconds !== void 0 ? { ptsSeconds: tag.ptsSeconds } : {},
raw: { id3: text }
};
return marker;
}
const daterangeMatch = text.match(/EXT-X-DATERANGE:([^\r\n]*)/i);
if (daterangeMatch) {
const attrs = this.parseAttributeList(daterangeMatch[1] ?? "");
const hasScteOut = "SCTE35-OUT" in attrs || attrs["SCTE35-OUT"] !== void 0;
const hasScteIn = "SCTE35-IN" in attrs || attrs["SCTE35-IN"] !== void 0;
const klass = String(attrs["CLASS"] ?? "");
const duration = this.toNumber(attrs["DURATION"]);
if (hasScteOut || /com\.apple\.hls\.cue/i.test(klass)) {
const marker = {
type: "start",
...tag.ptsSeconds !== void 0 ? { ptsSeconds: tag.ptsSeconds } : {},
...duration !== void 0 ? { durationSeconds: duration } : {},
raw: { id3: text, attrs }
};
return marker;
}
if (hasScteIn) {
const marker = {
type: "end",
...tag.ptsSeconds !== void 0 ? { ptsSeconds: tag.ptsSeconds } : {},
raw: { id3: text, attrs }
};
return marker;
}
}
if (/SCTE35-OUT/i.test(text)) {
const marker = {
type: "start",
...tag.ptsSeconds !== void 0 ? { ptsSeconds: tag.ptsSeconds } : {},
raw: { id3: text }
};
return marker;
}
if (/SCTE35-IN/i.test(text)) {
const marker = {
type: "end",
...tag.ptsSeconds !== void 0 ? { ptsSeconds: tag.ptsSeconds } : {},
raw: { id3: text }
};
return marker;
}
if (tag.value instanceof Uint8Array) {
const bin = this.parseScte35Binary(tag.value);
if (bin) return bin;
}
return void 0;
}
decodeId3ValueToText(value) {
try {
if (typeof value === "string") return value;
const decoder = new TextDecoder("utf-8", { fatal: false });
const text = decoder.decode(value);
if (text && /[\x20-\x7E]/.test(text)) return text;
let out = "";
for (let i = 0; i < value.length; i++)
out += String.fromCharCode(value[i]);
return out;
} catch {
return void 0;
}
}
onScte35Marker(marker) {
if (this.config.debugAdTiming) {
console.log("[StormcloudVideoPlayer] SCTE-35 marker detected:", {
type: marker.type,
ptsSeconds: marker.ptsSeconds,
durationSeconds: marker.durationSeconds,
currentTime: this.video.currentTime,
raw: marker.raw
});
}
if (marker.type === "start") {
this.inAdBreak = true;
const durationMs = marker.durationSeconds != null ? marker.durationSeconds * 1e3 : void 0;
this.expectedAdBreakDurationMs = durationMs;
this.currentAdBreakStartWallClockMs = Date.now();
const isManifestMarker = this.isManifestBasedMarker(marker);
const forceImmediate = this.config.immediateManifestAds ?? true;
if (this.config.debugAdTiming) {
console.log("[StormcloudVideoPlayer] Ad start decision:", {
isManifestMarker,
forceImmediate,
hasPts: typeof marker.ptsSeconds === "number"
});
}
if (isManifestMarker && forceImmediate) {
if (this.config.debugAdTiming) {
console.log(
"[StormcloudVideoPlayer] Starting ad immediately (manifest-based)"
);
}
this.clearAdStartTimer();
this.handleAdStart(marker);
} else if (typeof marker.ptsSeconds === "number") {
const tol = this.config.driftToleranceMs ?? 1e3;
const nowMs = this.video.currentTime * 1e3;
const estCurrentPtsMs = nowMs - this.ptsDriftEmaMs;
const deltaMs = Math.floor(marker.ptsSeconds * 1e3 - estCurrentPtsMs);
if (this.config.debugAdTiming) {
console.log("[StormcloudVideoPlayer] PTS-based timing calculation:", {
nowMs,
estCurrentPtsMs,
markerPtsMs: marker.ptsSeconds * 1e3,
deltaMs,
tolerance: tol
});
}
if (deltaMs > tol) {
if (this.config.debugAdTiming) {
console.log(
`[StormcloudVideoPlayer] Scheduling ad start in ${deltaMs}ms`
);
}
this.scheduleAdStartIn(deltaMs);
} else {
if (this.config.debugAdTiming) {
console.log(
"[StormcloudVideoPlayer] Starting ad immediately (within tolerance)"
);
}
this.clearAdStartTimer();
this.handleAdStart(marker);
}
} else {
if (this.config.debugAdTiming) {
console.log(
"[StormcloudVideoPlayer] Starting ad immediately (fallback)"
);
}
this.clearAdStartTimer();
this.handleAdStart(marker);
}
if (this.expectedAdBreakDurationMs != null) {
this.scheduleAdStopCountdown(this.expectedAdBreakDurationMs);
}
return;
}
if (marker.type === "progress" && this.inAdBreak) {
if (marker.durationSeconds != null) {
this.expectedAdBreakDurationMs = marker.durationSeconds * 1e3;
}
if (this.expectedAdBreakDurationMs != null && this.currentAdBreakStartWallClockMs != null) {
const elapsedMs = Date.now() - this.currentAdBreakStartWallClockMs;
const remainingMs = Math.max(
0,
this.expectedAdBreakDurationMs - elapsedMs
);
this.scheduleAdStopCountdown(remainingMs);
}
if (!this.ima.isAdPlaying()) {
const scheduled = this.findCurrentOrNextBreak(
this.video.currentTime * 1e3
);
const tags = this.selectVastTagsForBreak(scheduled) || (this.apiVastTagUrl ? [this.apiVastTagUrl] : void 0);
if (tags && tags.length > 0) {
const first = tags[0];
const rest = tags.slice(1);
this.adPodQueue = rest;
this.playSingleAd(first).catch(() => {
});
}
}
return;
}
if (marker.type === "end") {
this.inAdBreak = false;
this.expectedAdBreakDurationMs = void 0;
this.currentAdBreakStartWallClockMs = void 0;
this.clearAdStartTimer();
this.clearAdStopTimer();
if (this.ima.isAdPlaying()) {
this.ima.stop().catch(() => {
});
}
return;
}
}
parseCueOutDuration(value) {
const num = parseFloat(value.trim());
if (!Number.isNaN(num)) return num;
const match = value.match(/(?:^|[,\s])DURATION\s*=\s*([0-9.]+)/i) || value.match(/Duration\s*=\s*([0-9.]+)/i);
if (match && match[1] != null) {
const dStr = match[1];
const d = parseFloat(dStr);
return Number.isNaN(d) ? void 0 : d;
}
return void 0;
}
parseCueOutCont(value) {
const elapsedMatch = value.match(/Elapsed\s*=\s*([0-9.]+)/i);
const durationMatch = value.match(/Duration\s*=\s*([0-9.]+)/i);
const res = {};
if (elapsedMatch && elapsedMatch[1] != null) {
const e = parseFloat(elapsedMatch[1]);
if (!Number.isNaN(e)) res.elapsed = e;
}
if (durationMatch && durationMatch[1] != null) {
const d = parseFloat(durationMatch[1]);
if (!Number.isNaN(d)) res.duration = d;
}
if ("elapsed" in res || "duration" in res) return res;
return void 0;
}
parseAttributeList(value) {
const attrs = {};
const regex = /([A-Z0-9-]+)=(("[^"]*")|([^",]*))(?:,|$)/gi;
let match;
while ((match = regex.exec(value)) !== null) {
const key = match[1] ?? "";
let rawVal = match[3] ?? match[4] ?? "";
if (rawVal.startsWith('"') && rawVal.endsWith('"')) {
rawVal = rawVal.slice(1, -1);
}
if (key) {
attrs[key] = rawVal;
}
}
return attrs;
}
toNumber(val) {
if (val == null) return void 0;
const n = typeof val === "string" ? parseFloat(val) : Number(val);
return Number.isNaN(n) ? void 0 : n;
}
isManifestBasedMarker(marker) {
const raw = marker.raw;
if (!raw) return false;
if (raw.tag) {
const tag = String(raw.tag);
return tag.includes("EXT-X-CUE-OUT") || tag.includes("EXT-X-CUE-IN") || tag.includes("EXT-X-DATERANGE");
}
if (raw.id3) return false;
if (raw.splice_command_type) return false;
return false;
}
parseScte35Binary(data) {
class BitReader {
constructor(buf) {
this.buf = buf;
this.bytePos = 0;
this.bitPos = 0;
}
readBits(numBits) {
let result = 0;
while (numBits > 0) {
if (this.bytePos >= this.buf.length) return result;
const remainingInByte = 8 - this.bitPos;
const toRead = Math.min(numBits, remainingInByte);
const currentByte = this.buf[this.bytePos];
const shift = remainingInByte - toRead;
const mask = (1 << toRead) - 1 & 255;
const bits = currentByte >> shift & mask;
result = result << toRead | bits;
this.bitPos += toRead;
if (this.bitPos >= 8) {
this.bitPos = 0;
this.bytePos += 1;
}
numBits -= toRead;
}
return result >>> 0;
}
skipBits(n) {
this.readBits(n);
}
}
const r = new BitReader(data);
const tableId = r.readBits(8);
if (tableId !== 252) return void 0;
r.readBits(1);
r.readBits(1);
r.readBits(2);
const sectionLength = r.readBits(12);
r.readBits(8);
r.readBits(1);
r.readBits(6);
const ptsAdjHigh = r.readBits(1);
const ptsAdjLow = r.readBits(32);
void ptsAdjHigh;
void ptsAdjLow;
r.readBits(8);
r.readBits(12);
const spliceCommandLength = r.readBits(12);
const spliceCommandType = r.readBits(8);
if (spliceCommandType !== 5) {
return void 0;
}
r.readBits(32);
const cancel = r.readBits(1) === 1;
r.readBits(7);
if (cancel) return void 0;
const outOfNetwork = r.readBits(1) === 1;
const programSpliceFlag = r.readBits(1) === 1;
const durationFlag = r.readBits(1) === 1;
const spliceImmediateFlag = r.readBits(1) === 1;
r.readBits(4);
if (programSpliceFlag && !spliceImmediateFlag) {
const timeSpecifiedFlag = r.readBits(1) === 1;
if (timeSpecifiedFlag) {
r.readBits(6);
r.readBits(33);
} else {
r.readBits(7);
}
} else if (!programSpliceFlag) {
const componentCount = r.readBits(8);
for (let i = 0; i < componentCount; i++) {
r.readBits(8);
if (!spliceImmediateFlag) {
const timeSpecifiedFlag = r.readBits(1) === 1;
if (timeSpecifiedFlag) {
r.readBits(6);
r.readBits(33);
} else {
r.readBits(7);
}
}
}
}
let durationSeconds = void 0;
if (durationFlag) {
r.readBits(6);
r.readBits(1);
const high = r.readBits(1);
const low = r.readBits(32);
const durationTicks = high * 4294967296 + low;
durationSeconds = durationTicks / 9e4;
}
r.readBits(16);
r.readBits(8);
r.readBits(8);
if (outOfNetwork) {
const marker = {
type: "start",
...durationSeconds !== void 0 ? { durationSeconds } : {},
raw: { splice_command_type: 5 }
};
return marker;
}
return void 0;
}
initializeTracking() {
sendInitialTracking(this.config.licenseKey).catch((error) => {
if (this.config.debugAdTiming) {
console.warn(
"[StormcloudVideoPlayer] Failed to send initial tracking:",
error
);
}
});
this.heartbeatInterval = window.setInterval(() => {
this.sendHeartbeatIfNeeded();
}, 5e3);
}
sendHeartbeatIfNeeded() {
const now = Date.now();
if (!this.lastHeartbeatTime || now - this.lastHeartbeatTime > 3e4) {
this.lastHeartbeatTime = now;
sendHeartbeat(this.config.licenseKey).catch((error) => {
if (this.config.debugAdTiming) {
console.warn(
"[StormcloudVideoPlayer] Failed to send heartbeat:",
error
);
}
});
}
}
async fetchAdConfiguration() {
const apiUrl = "https://adstorm.co/api-adstorm-dev/adstorm/ads/web";
if (this.config.debugAdTiming) {
console.log(
"[StormcloudVideoPlayer] Fetching ad configuration from:",
apiUrl
);
}
const headers = {};
if (this.config.licenseKey) {
headers["Authorization"] = `Bearer ${this.config.licenseKey}`;
}
const response = await fetch(apiUrl, { headers });
if (!response.ok) {
throw new Error(`Failed to fetch ad configuration: ${response.status}`);
}
const data = await response.json();
const imaPayload = data.response?.ima?.["publisherdesk.ima"]?.payload;
if (imaPayload) {
this.apiVastTagUrl = decodeURIComponent(imaPayload);
if (this.config.debugAdTiming) {
console.log(
"[StormcloudVideoPlayer] Extracted VAST tag URL:",
this.apiVastTagUrl
);
}
}
this.vastConfig = data.response?.options?.vast;
if (this.config.debugAdTiming) {
console.log("[StormcloudVideoPlayer] Ad configuration loaded:", {
vastTagUrl: this.apiVastTagUrl,
vastConfig: this.vastConfig
});
}
}
getCurrentAdIndex() {
return this.currentAdIndex;
}
getTotalAdsInBreak() {
return this.totalAdsInBreak;
}
isAdPlaying() {
return this.inAdBreak && this.ima.isAdPlaying();
}
isShowingAds() {
return this.showAds;
}
getStreamType() {
const url = this.config.src.toLowerCase();
if (url.includes(".m3u8") || url.includes("/hls/") || url.includes("application/vnd.apple.mpegurl")) {
return "hls";
}
return "other";
}
shouldShowNativeControls() {
const streamType = this.getStreamType();
if (streamType === "other") {
return !(this.config.showCustomControls ?? false);
}
return !!(this.config.allowNativeHls && !(this.config.showCustomControls ?? false));
}
shouldContinueLiveStreamDuringAds() {
if (this.config.allowNativeHls) {
return false;
}
if (!this.isLiveStream) {
return false;
}
return true;
}
async loadDefaultVastFromAdstorm(adstormApiUrl, params) {
const usp = new URLSearchParams(params || {});
const url = `${adstormApiUrl}?${usp.toString()}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch adstorm ads: ${res.status}`);
const data = await res.json();
const tag = data?.adTagUrl || data?.vastTagUrl || data?.tagUrl;
if (typeof tag === "string" && tag.length > 0) {
this.apiVastTagUrl = tag;
}
}
async handleAdStart(_marker) {
const scheduled = this.findCurrentOrNextBreak(
this.video.currentTime * 1e3
);
const tags = this.selectVastTagsForBreak(scheduled);
let vastTagUrl;
let adsNumber = 1;
if (this.apiVastTagUrl) {
vastTagUrl = this.apiVastTagUrl;
if (this.vastConfig) {
const isHls = this.config.src.includes(".m3u8") || this.config.src.includes("hls");
if (isHls && this.vastConfig.cue_tones?.number_ads) {
adsNumber = this.vastConfig.cue_tones.number_ads;
} else if (!isHls && this.vastConfig.timer_vod?.number_ads) {
adsNumber = this.vastConfig.timer_vod.number_ads;
}
}
this.adPodQueue = new Array(adsNumber - 1).fill(vastTagUrl);
this.currentAdIndex = 0;
this.totalAdsInBreak = adsNumber;
if (this.config.debugAdTiming) {
console.log(
`[StormcloudVideoPlayer] Using API VAST tag with ${adsNumber} ads:`,
vastTagUrl
);
}
} else if (tags && tags.length > 0) {
vastTagUrl = tags[0];
const rest = tags.slice(1);
this.adPodQueue = rest;
this.currentAdIndex = 0;
this.totalAdsInBreak = tags.length;
if (this.config.debugAdTiming) {
console.log(
"[StormcloudVideoPlayer] Using scheduled VAST tag:",
vastTagUrl
);
}
} else {
if (this.config.debugAdTiming) {
console.log("[StormcloudVideoPlayer] No VAST tag available for ad");
}
return;
}
if (vastTagUrl) {
this.showAds = true;
this.currentAdIndex++;
await this.playSingleAd(vastTagUrl);
}
if (this.expectedAdBreakDurationMs == null && scheduled?.durationMs != null) {
this.expectedAdBreakDurationMs = scheduled.durationMs;
this.currentAdBreakStartWallClockMs = this.currentAdBreakStartWallClockMs ?? Date.now();
this.scheduleAdStopCountdown(this.expectedAdBreakDurationMs);
}
}
findCurrentOrNextBreak(nowMs) {
const schedule = [];
let candidate;
for (const b of schedule) {
const tol = this.config.driftToleranceMs ?? 1e3;
if (b.startTimeMs <= nowMs + tol && (candidate == null || b.startTimeMs > (candidate.startTimeMs || 0))) {
candidate = b;
}
}
return candidate;
}
onTimeUpdate(currentTimeSec) {
if (this.ima.isAdPlaying()) return;
const nowMs = currentTimeSec * 1e3;
const breakToPlay = this.findBreakForTime(nowMs);
if (breakToPlay) {
this.handleMidAdJoin(breakToPlay, nowMs);
}
}
async handleMidAdJoin(adBreak, nowMs) {
const durationMs = adBreak.durationMs ?? 0;
const endMs = adBreak.startTimeMs + durationMs;
if (durationMs > 0 && nowMs > adBreak.startTimeMs && nowMs < endMs) {
const remainingMs = endMs - nowMs;
const tags = this.selectVastTagsForBreak(adBreak) || (this.apiVastTagUrl ? [this.apiVastTagUrl] : void 0);
if (tags && tags.length > 0) {
const first = tags[0];
const rest = tags.slice(1);
this.adPodQueue = rest;
await this.playSingleAd(first);
this.inAdBreak = true;
this.expectedAdBreakDurationMs = remainingMs;
this.currentAdBreakStartWallClockMs = Date.now();
this.scheduleAdStopCountdown(remainingMs);
}
}
}
scheduleAdStopCountdown(remainingMs) {
this.clearAdStopTimer();
const ms = Math.max(0, Math.floor(remainingMs));
if (ms === 0) {
this.ensureAdStoppedByTimer();
return;
}
this.adStopTimerId = window.setTimeout(() => {
this.ensureAdStoppedByTimer();
}