stormcloud-video-player
Version: 
Ad-first HLS video player with SCTE-35 support and Google IMA integration for precise ad break alignment
1,470 lines (1,464 loc) • 148 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
  // If the importer is in node compatibility mode or this is not an ESM
  // file that has been converted to a CommonJS file using a Babel-
  // compatible transform (i.e. "__esModule" has not been set), then set
  // "default" to the CommonJS "module.exports" for node compatibility.
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
  mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
  IS_BROWSER: () => IS_BROWSER,
  IS_GLOBAL: () => IS_GLOBAL,
  IS_IOS: () => IS_IOS,
  IS_SAFARI: () => IS_SAFARI,
  SUPPORTS_DASH: () => SUPPORTS_DASH,
  SUPPORTS_HLS: () => SUPPORTS_HLS,
  StormcloudPlayer: () => StormcloudPlayer_default,
  StormcloudVideoPlayer: () => StormcloudVideoPlayer,
  StormcloudVideoPlayerComponent: () => StormcloudVideoPlayerComponent,
  canPlay: () => canPlay,
  createStormcloudPlayer: () => createStormcloudPlayer,
  default: () => StormcloudVideoPlayerComponent,
  getBrowserID: () => getBrowserID,
  getClientInfo: () => getClientInfo,
  isMediaStream: () => isMediaStream,
  lazy: () => lazy,
  merge: () => merge,
  omit: () => omit,
  parseQuery: () => parseQuery,
  players: () => players_default,
  randomString: () => randomString,
  sendHeartbeat: () => sendHeartbeat,
  sendInitialTracking: () => sendInitialTracking,
  supportsWebKitPresentationMode: () => supportsWebKitPresentationMode
});
module.exports = __toCommonJS(index_exports);
// src/ui/StormcloudVideoPlayer.tsx
var import_react = __toESM(require("react"), 1);
// src/player/StormcloudVideoPlayer.ts
var import_hls = __toESM(require("hls.js"), 1);
// 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 import_hls.default({
      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(import_hls.default.Events.MEDIA_ATTACHED, () => {
      this.hls?.loadSource(this.config.src);
    });
    this.hls.on(import_hls.default.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(import_hls.default.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(import_hls.default.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(import_hls.default.Events.ERROR, (_evt, data) => {
      if (data?.fatal) {
        switch (data.type) {
          case import_hls.default.ErrorTypes.NETWORK_ERROR:
            this.hls?.startLoad();
            break;
          case import_hls.default.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.len