UNPKG

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
"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