UNPKG

@interactive-video-labs/core

Version:
919 lines (896 loc) 28.8 kB
/** * * Framework-agnostic interactive video engine core * @interactive-video-labs/core v0.2.0 * @author Taj * @license MIT * */ "use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; 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 __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { Analytics: () => Analytics, CueHandler: () => CueHandler, IVLabsPlayer: () => IVLabsPlayer, InteractionManager: () => InteractionManager, StateMachine: () => StateMachine }); module.exports = __toCommonJS(index_exports); // src/stateMachine.ts var StateMachine = class { /** * Creates an instance of StateMachine. * @param initialState - The initial state of the state machine. */ constructor(initialState) { this.transitions = []; this.currentState = initialState; } /** * Gets the current state of the state machine. * @returns The current state. */ getState() { return this.currentState; } /** * Adds a new state transition to the state machine. * @param transition - The state transition to be added. */ addTransition(transition) { this.transitions.push(transition); } /** * Transitions to a new state if the transition is valid. * @param targetState - The state to transition to. * @returns True if the transition was successful, false otherwise. */ transitionTo(targetState) { const valid = this.transitions.find( (t) => t.from === this.currentState && t.to === targetState && (!t.condition || t.condition()) ); if (valid) { valid.action?.(); this.currentState = targetState; return true; } return false; } /** * Resets the state machine to its initial state. * @param initialState - Optional initial state to reset to. */ reset(initialState) { this.currentState = initialState || this.transitions[0]?.from || this.currentState; } }; // src/cueHandler.ts var CueHandler = class { /** * Creates an instance of CueHandler. * @param video - The HTMLVideoElement to monitor for cue points. */ constructor(video) { this.cues = []; this.triggered = /* @__PURE__ */ new Set(); this.videoElement = video; this.handleTimeUpdate = this.handleTimeUpdate.bind(this); } /** * Registers cue points and sets up the video element to listen for time updates. * @param cues - An array of Cue objects to register. */ registerCues(cues) { this.cues = cues.sort((a, b) => a.time - b.time); this.triggered.clear(); } loadCues(cues) { this.registerCues(cues); this.videoElement.addEventListener("timeupdate", this.handleTimeUpdate); } /** * Sets the callback function to be called when a cue point is reached. * @param callback - The callback function to be executed. */ onCue(callback) { this.callback = callback; } /** * Starts listening for cue points. */ start() { this.videoElement.addEventListener("timeupdate", this.handleTimeUpdate); } /** * Stops listening for cue points. */ stop() { this.videoElement.removeEventListener("timeupdate", this.handleTimeUpdate); } /** * Handles the time update event from the video element. * Checks if the current time matches any registered cue points and triggers the callback. */ handleTimeUpdate() { const currentTime = this.videoElement.currentTime; for (const cue of this.cues) { if (!this.triggered.has(cue.time) && currentTime >= cue.time) { this.triggered.add(cue.time); this.callback?.(cue); } } } /** * Destroys the CueHandler instance. */ destroy() { this.triggered.clear(); } }; // src/interactionManager.ts var InteractionManager = class { constructor(container, i18n, decisionAdapter) { this.interactionDiv = null; this.interactionRenderers = { choice: this.renderChoiceInteraction.bind(this), text: this.renderTextInteraction.bind(this), rating: this.renderRatingInteraction.bind(this), "choice-video-segment-change": this.renderChoiceVideoSegmentChangeInteraction.bind(this), default: this.renderDefaultInteraction.bind(this) }; this.container = container; this.i18n = i18n; this.decisionAdapter = decisionAdapter; this.interactionStore = /* @__PURE__ */ new Map(); } loadInteractions(interactions) { interactions.forEach((cue) => { if (cue.time != null && cue.payload?.interaction) { this.interactionStore.set(cue.time, cue); } }); } onPrompt(handler) { this.onPromptCallback = handler; } onResponse(handler) { this.onResponseCallback = handler; } handleInteractionCue(cue) { const payload = cue.payload?.interaction; if (payload) { this.onPromptCallback?.(payload, cue); this.renderInteraction(payload, cue); } } renderInteraction(payload, cue) { this.clearInteractions(); this.interactionDiv = document.createElement("div"); this.interactionDiv.className = "ivl-interaction-overlay"; if (payload.title) { const title = document.createElement("h3"); title.textContent = this.i18n.translate(payload.title); this.interactionDiv.appendChild(title); } if (payload.description) { const desc = document.createElement("p"); desc.textContent = this.i18n.translate(payload.description); this.interactionDiv.appendChild(desc); } const renderer = this.interactionRenderers[payload.type] || this.interactionRenderers.default; renderer(payload, cue); this.container.appendChild(this.interactionDiv); } renderRatingInteraction(payload, cue) { if (!this.interactionDiv) return; const question = document.createElement("p"); question.textContent = this.i18n.translate(payload.question); this.interactionDiv.appendChild(question); const ratingContainer = document.createElement("div"); ratingContainer.className = "ivl-rating-container"; for (let i = 1; i <= 5; i++) { const button = document.createElement("button"); button.className = "ivl-rating-button"; button.dataset.response = String(i); button.textContent = `${i} \u2605`; ratingContainer.appendChild(button); } this.interactionDiv.appendChild(ratingContainer); ratingContainer.addEventListener("click", (event) => { const target = event.target; if (target.matches(".ivl-rating-button")) { this.handleUserResponse(target.dataset.response, cue); this.clearInteractions(); } }); } renderChoiceInteraction(payload, cue) { if (!this.interactionDiv) return; const question = document.createElement("p"); question.textContent = this.i18n.translate(payload.question); this.interactionDiv.appendChild(question); const buttonContainer = document.createElement("div"); buttonContainer.className = "ivl-choice-buttons"; payload.options.forEach((option) => { const button = document.createElement("button"); button.className = "ivl-choice-button"; button.dataset.response = option; button.textContent = this.i18n.translate(option); buttonContainer.appendChild(button); }); this.interactionDiv.appendChild(buttonContainer); buttonContainer.addEventListener("click", (event) => { const target = event.target; if (target.matches(".ivl-choice-button")) { this.handleUserResponse(target.dataset.response, cue); this.clearInteractions(); } }); } renderChoiceVideoSegmentChangeInteraction(payload, cue) { if (!this.interactionDiv) return; const question = document.createElement("p"); question.textContent = this.i18n.translate(payload.question); this.interactionDiv.appendChild(question); const buttonContainer = document.createElement("div"); buttonContainer.className = "ivl-choice-buttons"; payload.options.forEach((option) => { const button = document.createElement("button"); button.className = "ivl-choice-button"; button.dataset.response = option.video; button.textContent = this.i18n.translate(option.level); buttonContainer.appendChild(button); }); this.interactionDiv.appendChild(buttonContainer); buttonContainer.addEventListener("click", (event) => { const target = event.target; if (target.matches(".ivl-choice-button")) { this.handleUserResponse(target.dataset.response, cue); this.clearInteractions(); } }); } renderTextInteraction(payload, cue) { if (!this.interactionDiv) return; const question = document.createElement("p"); question.textContent = this.i18n.translate(payload.question); this.interactionDiv.appendChild(question); const input = document.createElement("input"); input.type = "text"; input.placeholder = this.i18n.translate("Enter your response"); this.interactionDiv.appendChild(input); const button = document.createElement("button"); button.textContent = this.i18n.translate("Submit"); this.interactionDiv.appendChild(button); button.addEventListener("click", () => { this.handleUserResponse(input.value, cue); this.clearInteractions(); }); } renderDefaultInteraction(payload, cue) { if (!this.interactionDiv) return; const question = document.createElement("p"); question.textContent = this.i18n.translate(payload.question); this.interactionDiv.appendChild(question); const button = document.createElement("button"); button.textContent = this.i18n.translate("Respond"); this.interactionDiv.appendChild(button); button.addEventListener("click", () => { this.handleUserResponse("User responded!", cue); this.clearInteractions(); }); } clearInteractions() { this.interactionDiv?.remove(); this.interactionDiv = null; } //eslint-disable-next-line @typescript-eslint/no-explicit-any handleUserResponse(response, cue) { const payload = cue.payload?.interaction; if (!this.onResponseCallback) return; const decision = { cueId: cue.id, choice: response, timestamp: Date.now(), metadata: { interactionType: payload?.type } }; this.decisionAdapter.saveDecision(decision); if (payload?.type === "choice-video-segment-change") { this.onResponseCallback({ nextSegment: response }, cue); } else if (payload && typeof payload.response === "object" && payload.response[response]) { this.onResponseCallback(payload.response[response], cue); } else { this.onResponseCallback(response, cue); } } destroy() { this.onPromptCallback = void 0; this.onResponseCallback = void 0; this.clearInteractions(); this.interactionStore.clear(); } }; // src/analytics.ts var Analytics = class { /** * Initializes the Analytics instance. * @param options - Optional configuration for the analytics instance. */ constructor(options = {}) { this.options = options; this.hooks = {}; if (this.options.debug) { console.log("[Analytics] Initialized with options:", this.options); } } /** * Registers a new analytics hook for a specific event. * @param event - The event to listen for. * @param hook - The function to be called when the event is tracked. */ on(event, hook) { if (!this.hooks[event]) { this.hooks[event] = []; } this.hooks[event].push(hook); } /** * Tracks an event with optional payload. * @param event - The event to be tracked. * @param payload - Optional data associated with the event. */ track(event, payload) { if (this.hooks[event]) { this.hooks[event].forEach((hook) => hook(event, payload)); } } /** * Resets the analytics hooks. */ reset() { this.hooks = {}; } }; // src/segmentManager.ts var SegmentManager = class { constructor(videoElement) { this._mainVideoSrc = null; this._mainVideoCurrentTime = 0; this.videoElement = videoElement; this.bindEvents(); } bindEvents() { this.videoElement.addEventListener("ended", () => { if (this._mainVideoSrc) { console.log("Segment ended. Resuming main video:", this._mainVideoSrc); const resumeSrc = this._mainVideoSrc; const resumeTime = this._mainVideoCurrentTime; this._mainVideoSrc = null; this._mainVideoCurrentTime = 0; this.videoElement.src = resumeSrc; this.videoElement.load(); this.videoElement.addEventListener( "loadedmetadata", () => { this.videoElement.currentTime = resumeTime; this.videoElement.play().catch((err) => { console.error("Error resuming main video:", err); }); }, { once: true } ); } }); } /** * Handles the transition to a new video segment. * Saves the current main video state and plays the new segment. * @param newSegmentUrl The URL of the new video segment to play. */ playSegment(newSegmentUrl) { this._mainVideoSrc = this.videoElement.src; this._mainVideoCurrentTime = this.videoElement.currentTime; this.videoElement.src = newSegmentUrl; this.videoElement.load(); this.videoElement.addEventListener( "loadedmetadata", () => { this.videoElement.play().catch((error) => { console.error("Error playing segment video:", error); }); console.log("Playing segment video:", newSegmentUrl); }, { once: true } ); } /** * Destroys the SegmentChangeManager instance. * Removes event listeners. */ destroy() { } }; // src/i18n.ts var I18n = class { constructor() { this.translations = {}; this.locale = "en"; } /** * Loads translations for a specific locale. * @param locale - The locale to load translations for. * @param translations - The translation data. */ load(locale, translations) { this.translations[locale] = translations; } /** * Sets the current locale for localization. * @param locale - The new locale. */ setLocale(locale) { this.locale = locale; } /** * Translates a key to the current locale. * @param key - The key to translate. * @param options - Optional placeholders to replace in the translation. * @returns The translated string or the original key if not found. */ translate(key, options) { const translation = this.getTranslation(key, this.locale) || this.getTranslation(key, "en"); if (!translation) { return key; } if (typeof translation === "string") { return this.replacePlaceholders(translation, options); } return key; } /** * Gets the translation for a key in a specific locale. * @param key - The key to translate. * @param locale - The locale to get the translation for. * @returns The translated string or undefined if not found. */ getTranslation(key, locale) { const keys = key.split("."); let current = this.translations[locale]; for (const k of keys) { if (current && typeof current === "object" && k in current) { current = current[k]; } else { return void 0; } } return typeof current === "string" ? current : void 0; } /** * Replaces placeholders in a translation string. * @param text - The translation string with placeholders. * @param options - Optional placeholders to replace in the translation. * @returns The translated string with placeholders replaced. */ replacePlaceholders(text, options) { if (!options) { return text; } return Object.entries(options).reduce((acc, [key, value]) => { return acc.replace(`{{${key}}}`, value); }, text); } }; // src/InMemoryDecisionAdapter.ts var InMemoryDecisionAdapter = class { constructor() { this.decisions = []; } /** * Saves a decision to the in-memory store. * @param decision - The decision to save. */ async saveDecision(decision) { this.decisions.push(decision); } /** * Retrieves the decision history from the in-memory store. * @returns A promise that resolves to an array of decisions. */ async getDecisionHistory() { return [...this.decisions]; } /** * Clears the decision history in the in-memory store. */ async clearDecisionHistory() { this.decisions = []; } }; // src/localStorageDecisionAdapter.ts var LocalStorageDecisionAdapter = class { constructor() { this.STORAGE_KEY = "ivl_decision_history"; } /** * Saves a decision to the local storage. * @param decision - The decision to save. */ async saveDecision(decision) { try { const history = await this.getDecisionHistory(); history.push(decision); localStorage.setItem(this.STORAGE_KEY, JSON.stringify(history)); } catch (error) { console.error("Failed to save decision to localStorage:", error); throw new Error("Decision storage failed"); } } /** * Retrieves the decision history from local storage. * @returns A promise that resolves to an array of decisions. */ async getDecisionHistory() { try { const history = localStorage.getItem(this.STORAGE_KEY); return history ? JSON.parse(history) : []; } catch (error) { console.error("Failed to get decision history from localStorage:", error); throw new Error("Decision retrieval failed"); } } /** * Clears the decision history in local storage. */ async clearDecisionHistory() { try { localStorage.removeItem(this.STORAGE_KEY); } catch (error) { console.error("Failed to clear decision history from localStorage:", error); throw new Error("Failed to clear decision history"); } } }; // src/style.ts var FALLBACK_CSS = ` .ivl-player-container { font-family: Arial, sans-serif; line-height: 1.6; color: #333; } .ivl-player-container h1, .ivl-player-container h2, .ivl-player-container h3, .ivl-player-container h4, .ivl-player-container h5, .ivl-player-container h6 { color: #000; margin-top: 1em; margin-bottom: 0.5em; } .ivl-player-container { position: relative; max-width: 800px; width: 100%; border: 1px solid #ccc; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); } video { width: 100%; display: block; } .ivl-interaction-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: rgba(0, 0, 0, 0.7); color: white; padding: 20px; border-radius: 8px; text-align: center; z-index: 10; width: 80%; max-width: 400px; } .ivl-interaction-overlay h3 { margin-top: 0; color: #4CAF50; } .ivl-interaction-overlay button { background-color: #4CAF50; color: white; border: none; padding: 10px 20px; font-size: 16px; margin-top: 15px; cursor: pointer; border-radius: 5px; } .ivl-interaction-overlay button:hover { background-color: #45a049; } .controls { margin-top: 20px; display: flex; gap: 10px; } button, select { padding: 10px 20px; font-size: 16px; cursor: pointer; } `; // src/subtitlesManager.ts var SubtitlesManager = class { /** * Creates an instance of SubtitlesManager. * @param subtitlesElement - The HTML track element for subtitles. * @param cuePoints - Optional array of cue points to initialize with. */ constructor(subtitlesElement, cuePoints = []) { this.cuePoints = []; this.subtitlesElement = subtitlesElement; this.cuePoints = cuePoints; } /** * Registers a callback to be invoked when subtitles are loaded or an error occurs. * @param callback - A function to be called with the loaded cues or the initial cues if an error occurs. */ onLoad(callback) { this.subtitlesElement.addEventListener("load", () => { const track = this.subtitlesElement.track; if (!track) { console.warn("Subtitle track not available"); callback(this.cuePoints); return; } const trackCues = Array.from(track.cues || []); const cues = this.cuePoints.map((cueConfig) => ({ ...cueConfig, time: trackCues.find((cue) => cue?.text === cueConfig.subtitleText)?.startTime || 0 })); callback(cues); }); this.subtitlesElement.addEventListener("error", (error) => { console.error("Failed to load subtitles:", error); callback(this.cuePoints); }); } }; // src/player.ts var IVLabsPlayer = class { constructor(target, config) { const targetElement = document.getElementById(target); if (!targetElement) throw new Error(`Target container with ID "${target}" not found.`); this.config = config; this.videoContainer = document.createElement("div"); this.videoContainer.className = "ivl-player-container"; this.videoElement = document.createElement("video"); this.videoElement.controls = true; this.videoElement.controlsList = "nodownload"; if (config.subtitlesUrl) { this.subtitlesElement = document.createElement("track"); this.subtitlesElement.src = config.subtitlesUrl; this.subtitlesElement.kind = "subtitles"; this.subtitlesElement.default = true; this.subtitlesManager = new SubtitlesManager(this.subtitlesElement, config.cues); this.videoElement.appendChild(this.subtitlesElement); this.subtitlesManager.onLoad((cues) => { this.start(cues); }); } this.videoContainer.appendChild(this.videoElement); targetElement.innerHTML = ""; targetElement.appendChild(this.videoContainer); this._injectFallbackCss(); this.i18n = new I18n(); if (config.translations) { for (const [locale, translations] of Object.entries(config.translations)) { this.i18n.load(locale, translations); } } if (config.locale) { this.i18n.setLocale(config.locale); } if (config.decisionAdapter) { this.decisionAdapter = config.decisionAdapter; } else if (config.decisionAdapterType === "localStorage") { this.decisionAdapter = new LocalStorageDecisionAdapter(); } else { this.decisionAdapter = new InMemoryDecisionAdapter(); } this.analytics = new Analytics(); this.stateMachine = new StateMachine(config.initialState || "idle"); this.interactionManager = new InteractionManager( this.videoContainer, this.i18n, this.decisionAdapter ); this.cueHandler = new CueHandler(this.videoElement); this.segmentManager = new SegmentManager(this.videoElement); if (!config.subtitlesUrl) { this.start(config.cues); } } start(cues) { this.cueHandler.registerCues(cues || []); if (!this.config.videoUrl) throw new Error("videoUrl must be provided in the PlayerConfig."); this.videoElement.src = this.config.videoUrl; this.bindEvents(); this.cueHandler.start(); } /** * Binds event listeners for cue handling, interaction response, and analytics. */ bindEvents() { this.cueHandler.onCue((cue) => { this.analytics.track("onCueEnter", { event: "onCueEnter", cueId: cue.id, timestamp: Date.now() }); if (cue.payload?.interaction) { this.videoElement.pause(); this.stateMachine.transitionTo("waitingForInteraction"); this.interactionManager.handleInteractionCue(cue); this.analytics.track("onPromptShown", { event: "onPromptShown", cueId: cue.id, timestamp: Date.now() }); } else { this.stateMachine.transitionTo("playing"); } }); this.interactionManager.onResponse((response, cue) => { this.analytics.track("onInteractionSelected", { event: "onInteractionSelected", cueId: cue.id, data: { response }, timestamp: Date.now() }); if (response && response.nextSegment) { this.analytics.track("onBranchJump", { event: "onBranchJump", cueId: cue.id, data: { nextSegment: response.nextSegment }, timestamp: Date.now() }); this.segmentManager.playSegment(response.nextSegment); } else { this.videoElement.play().catch((error) => { console.error("Video playback failed:", error); }); } console.log("Interaction response received:", response); this.stateMachine.transitionTo("playing"); this.analytics.track("INTERACTION_COMPLETED", { event: "INTERACTION_COMPLETED", cueId: cue.id, data: { response }, timestamp: Date.now() }); }); this.videoElement.addEventListener("play", () => { this.analytics.track("VIDEO_STARTED", { event: "VIDEO_STARTED", timestamp: Date.now() }); }); this.videoElement.addEventListener("pause", () => { this.analytics.track("VIDEO_PAUSED", { event: "VIDEO_PAUSED", timestamp: Date.now() }); }); this.videoElement.addEventListener("ended", () => { this.analytics.track("VIDEO_ENDED", { event: "VIDEO_ENDED", timestamp: Date.now() }); }); } /** Loads cue points into the player. */ loadCues(cues) { this.cueHandler.loadCues(cues); } /** Loads interaction segments into the player. */ loadInteractions(interactions) { this.interactionManager.loadInteractions(interactions); } /** Returns the current player state. */ getState() { return this.stateMachine.getState(); } /** Plays the video. */ play() { this.videoElement.play(); } /** Pauses the video. */ pause() { this.videoElement.pause(); } /** * Registers a custom analytics hook for a specific event. * @param event - The event to listen for. * @param callback - The function to be called when the event is tracked. */ on(event, callback) { this.analytics.on(event, (event2, payload) => callback(payload)); } /** * Sets the current language for localization. * @param locale - The new locale. */ setLocale(locale) { this.i18n.setLocale(locale); } /** * Loads a set of translations for a specific locale. * @param locale - The locale for the translations. * @param translations - The translation data. */ loadTranslations(locale, translations) { this.i18n.load(locale, translations); } /** * Retrieves the user's decision history. * @returns A promise that resolves to an array of Decision objects. */ getDecisionHistory() { return this.decisionAdapter.getDecisionHistory(); } /** * Clears the user's decision history. * @returns A promise that resolves when the history is cleared. */ clearDecisionHistory() { return this.decisionAdapter.clearDecisionHistory(); } /** * Injects fallback CSS into the document head. * This ensures basic styling is present even if external stylesheets are not loaded. */ _injectFallbackCss() { const style = document.createElement("style"); style.textContent = FALLBACK_CSS; document.head.appendChild(style); } /** Cleans up the player, removes listeners and resets state. */ destroy() { console.log("IVLabsPlayer destroy() called."); this.cueHandler.destroy(); this.interactionManager.destroy(); this.analytics.track("onSessionEnd", { event: "onSessionEnd", timestamp: Date.now() }); } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { Analytics, CueHandler, IVLabsPlayer, InteractionManager, StateMachine });