saltfish
Version:
An interactive video-guided tour system for web applications
8,602 lines • 329 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
const baseResetCss = "/* \n * CSS Reset for the Saltfish playlist Player\n * Minimal reset for the Shadow DOM to ensure consistent rendering\n */\n\n:host {\n all: initial;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;\n box-sizing: border-box;\n}\n\n:host *,\n:host *::before,\n:host *::after {\n box-sizing: inherit;\n margin: 0;\n padding: 0;\n}\n\nbutton {\n background: none;\n border: none;\n cursor: pointer;\n font: inherit;\n outline: none;\n padding: 0;\n} ";
const baseVariablesCss = "/* \n * Variables for the Saltfish playlist Player\n * Defines all design tokens used throughout the application\n */\n\n:host {\n /* Colors */\n --sf-primary-color: #4a9bff;\n --sf-secondary-color: #6ccfff;\n --sf-background-color: #1e1e1e;\n --sf-text-color: #ffffff;\n --sf-button-bg: rgba(0, 0, 0, 0.5);\n --sf-button-hover-bg: rgba(0, 0, 0, 0.7);\n --sf-overlay-gradient: linear-gradient(180deg, rgba(0, 0, 0, 0.7) 0%, transparent 30%, transparent 70%, rgba(0, 0, 0, 0.7) 100%);\n --sf-progress-gradient: linear-gradient(90deg, var(--sf-primary-color), var(--sf-secondary-color));\n --sf-error-color: #ff4d4d;\n --sf-error-bg: rgba(255, 77, 77, 0.1);\n \n /* Spacing */\n --sf-spacing-xs: 4px;\n --sf-spacing-sm: 8px;\n --sf-spacing-md: 12px;\n --sf-spacing-lg: 16px;\n --sf-spacing-xl: 24px;\n \n /* Sizes */\n --sf-player-width: 240px;\n --sf-player-height: 336px;\n --sf-player-min-width: 80px;\n --sf-player-min-height: 80px;\n --sf-control-button-size: 24px;\n --sf-play-button-size: 60px;\n --sf-minimize-button-size: 20px;\n --sf-mute-button-size: 32px;\n --sf-cc-button-size: 32px;\n --sf-cursor-size: 32px;\n \n /* Border radius */\n --sf-border-radius-sm: 4px;\n --sf-border-radius-md: 8px;\n --sf-border-radius-lg: 16px;\n --sf-border-radius-circle: 50%;\n \n /* Transitions */\n --sf-transition-fast: 0.1s ease;\n --sf-transition-normal: 0.2s ease;\n --sf-transition-slow: 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);\n \n /* Shadows */\n --sf-shadow-small: 0 2px 5px rgba(0, 0, 0, 0.2);\n --sf-shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.15);\n --sf-shadow-large: 0 10px 25px rgba(0, 0, 0, 0.2);\n \n /* Z-index layering */\n --sf-z-index-base: 1;\n --sf-z-index-overlay: 2;\n --sf-z-index-controls: 10;\n --sf-z-index-cursor: 9999;\n --sf-z-index-player: 2147483648;\n \n /* Font sizes */\n --sf-font-size-sm: 14px;\n --sf-font-size-md: 16px;\n --sf-font-size-lg: 18px;\n --sf-font-size-xl: 24px;\n} \n\n/* Mobile device responsive adjustments - make player smaller for mobile screens */\n@media (max-width: 768px) {\n :host {\n /* Reduce player size on mobile for better space utilization */\n --sf-player-width: 180px; /* 25% smaller than desktop (240px -> 180px) */\n --sf-player-height: 252px; /* 25% smaller than desktop (336px -> 252px) */\n --sf-player-min-width: 60px; /* Smaller when minimized (80px -> 60px) */\n --sf-player-min-height: 60px; /* Smaller when minimized (80px -> 60px) */\n \n /* Keep controls touch-friendly despite smaller player size */\n --sf-play-button-size: 44px; /* Smaller but still touch-friendly (60px -> 44px) */\n --sf-control-button-size: 28px; /* Keep larger for touch targets (24px -> 28px) */\n --sf-mute-button-size: 26px; /* Smaller for mobile (32px -> 26px) */\n --sf-cc-button-size: 26px; /* Smaller for mobile (32px -> 26px) */\n --sf-minimize-button-size: 24px; /* Keep larger for touch interaction */\n }\n}\n\n/* Touch device specific adjustments (tablets and larger touch devices, excluding mobile) */\n@media (pointer: coarse) and (min-width: 769px) {\n :host {\n /* Ensure touch-friendly sizes even on larger touch devices */\n --sf-control-button-size: 28px;\n --sf-mute-button-size: 38px;\n --sf-cc-button-size: 38px;\n --sf-minimize-button-size: 24px; /* Larger touch target for minimize button */\n }\n} ";
const componentsPlayerCss = "/* \n * Player component styles for the Saltfish playlist Player\n * Following BEM naming convention\n */\n\n/* Main player container */\n.sf-player {\n border-radius: var(--sf-border-radius-lg);\n box-shadow: 0 25px 50px rgba(0, 0, 0, 0.45), 0 10px 20px rgba(0, 0, 0, 0.3), 0 0 0 2px rgba(255, 255, 255, 0.08);\n transition: all var(--sf-transition-slow);\n position: relative;\n backdrop-filter: blur(10px);\n -webkit-backdrop-filter: blur(10px);\n}\n\n/* Dark gradient overlay at bottom of player */\n.sf-player::before {\n content: '';\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 33.33%; /* One third of player height */\n background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 100%);\n pointer-events: none;\n z-index: var(--sf-z-index-overlay);\n border-radius: 8px;\n}\n\n/* Hide gradient overlay when minimized */\n.sf-player--minimized::before {\n display: none;\n}\n\n/* Full-size player state */\n.sf-player:not(.sf-player--minimized) {\n width: var(--sf-player-width);\n height: var(--sf-player-height);\n}\n\n/* Autoplay fallback state - ensure play button is visible */\n.sf-player--waiting-for-user-interaction .sf-controls-container__play-button {\n display: flex !important;\n opacity: 1 !important;\n visibility: visible !important;\n}\n\n/* Also show the center play button in autoplay fallback state */\n.sf-player--waiting-for-user-interaction .sf-player__center-play-button {\n display: flex !important;\n opacity: 1 !important;\n z-index: calc(var(--sf-z-index-controls) + 20) !important; /* Higher z-index to appear above overlay */\n}\n\n/* Make the autoplay fallback state more prominent to indicate need for interaction */\n.sf-player--waiting-for-user-interaction::after {\n content: '';\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.3);\n pointer-events: none;\n z-index: var(--sf-z-index-overlay);\n}\n\n/* Player state: minimized */\n.sf-player--minimized {\n /* Equal width and height are essential for maintaining a perfect circle when using border-radius: 50% */\n width: var(--sf-player-min-width);\n height: var(--sf-player-min-height);\n border-radius: var(--sf-border-radius-circle);\n box-shadow: 0 15px 30px rgba(0, 0, 0, 0.35), 0 5px 15px rgba(0, 0, 0, 0.25), 0 0 0 2px rgba(255, 255, 255, 0.08);\n cursor: pointer;\n /* Force overriding any inline styles that might be applied */\n max-width: var(--sf-player-min-width) !important;\n max-height: var(--sf-player-min-height) !important;\n min-width: var(--sf-player-min-width) !important;\n min-height: var(--sf-player-min-height) !important;\n}\n\n/* Hide controls when minimized */\n.sf-player--minimized .sf-controls-container {\n display: none;\n}\n\n/* Only show the minimize button when hovering on minimized player */\n.sf-player--minimized .sf-player__minimize-button {\n opacity: 0;\n}\n\n.sf-player--minimized:hover .sf-player__minimize-button {\n opacity: 1;\n}\n\n/* Player root element */\n#sf-player-root {\n position: fixed;\n z-index: var(--sf-z-index-player);\n}\n\n/* Player error message */\n.sf-player__error {\n padding: var(--sf-spacing-md);\n color: var(--sf-error-color);\n background-color: var(--sf-error-bg);\n border-radius: var(--sf-border-radius-md);\n margin: var(--sf-spacing-sm);\n font-size: var(--sf-font-size-sm);\n border-left: 4px solid var(--sf-error-color);\n}\n\n/* Minimize button */\n.sf-player__minimize-button {\n position: absolute;\n top: calc(var(--sf-spacing-xs) + var(--sf-spacing-md));\n right: var(--sf-spacing-md);\n width: var(--sf-minimize-button-size);\n height: var(--sf-minimize-button-size);\n background-color: transparent;\n border-radius: var(--sf-border-radius-circle);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n z-index: var(--sf-z-index-controls);\n color: white;\n border: none;\n font-size: calc(var(--sf-font-size-sm) + 4px);\n transition: all var(--sf-transition-normal);\n text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n opacity: 0;\n}\n\n/* Minimize button hover state */\n.sf-player__minimize-button:hover {\n transform: scale(1.1);\n}\n\n/* Show minimize button on player hover */\n.sf-player:hover .sf-player__minimize-button {\n opacity: 1;\n}\n\n/* Mobile and touch device overrides for minimize button visibility */\n/* Ensure minimize button is always visible on touch devices, even when minimized */\n@media (pointer: coarse) {\n .sf-player__minimize-button {\n opacity: 1 !important;\n z-index: calc(var(--sf-z-index-controls) + 50) !important; /* Much higher z-index for touch devices */\n }\n \n .sf-player--minimized .sf-player__minimize-button {\n opacity: 1 !important;\n z-index: calc(var(--sf-z-index-controls) + 50) !important; /* Much higher z-index for touch devices */\n }\n}\n\n/* Ensure minimize button is always visible on mobile screens under 768px */\n@media (max-width: 768px) {\n .sf-player__minimize-button {\n opacity: 1 !important;\n z-index: calc(var(--sf-z-index-controls) + 50) !important; /* Much higher z-index for mobile */\n /* Position closer to top right corner on mobile */\n top: var(--sf-spacing-xs) !important; /* 4px from top instead of 16px */\n right: var(--sf-spacing-xs) !important; /* 4px from right instead of 12px */\n }\n \n .sf-player--minimized .sf-player__minimize-button {\n opacity: 1 !important;\n z-index: calc(var(--sf-z-index-controls) + 50) !important; /* Much higher z-index for mobile */\n /* Position closer to top right corner on mobile */\n top: var(--sf-spacing-xs) !important; /* 4px from top instead of 16px */\n right: var(--sf-spacing-xs) !important; /* 4px from right instead of 12px */\n }\n}\n\n/* Player title */\n.sf-player__title {\n position: absolute;\n top: var(--sf-spacing-md);\n left: var(--sf-spacing-md);\n color: var(--sf-text-color);\n font-size: var(--sf-font-size-md);\n font-weight: 600;\n z-index: var(--sf-z-index-controls);\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);\n max-width: 70%;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n/* Centered play/pause button overlay */\n.sf-player__center-play-button {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: var(--sf-play-button-size);\n height: var(--sf-play-button-size);\n background-color: rgba(0, 0, 0, 0.5);\n border-radius: var(--sf-border-radius-circle);\n display: none; /* Hidden by default */\n justify-content: center;\n align-items: center;\n z-index: calc(var(--sf-z-index-controls) + 10); /* Ensure higher z-index than other elements */\n color: white;\n border: none;\n font-size: var(--sf-control-button-size);\n cursor: pointer;\n transition: transform var(--sf-transition-normal), background-color var(--sf-transition-normal);\n backdrop-filter: blur(3px);\n -webkit-backdrop-filter: blur(3px);\n box-shadow: 0 15px 30px rgba(0, 0, 0, 0.5), 0 5px 15px rgba(0, 0, 0, 0.3), 0 0 0 2px rgba(255, 255, 255, 0.08);\n pointer-events: auto; /* Enable pointer events to capture clicks */\n}\n\n/* Center play button hover state */\n.sf-player__center-play-button:hover {\n transform: translate(-50%, -50%) scale(1.1);\n background-color: rgba(0, 0, 0, 0.7);\n}\n\n/* Hide center play button in minimized state */\n.sf-player--minimized .sf-player__center-play-button {\n display: none !important;\n}\n\n/* Exit button for minimized mode */\n.sf-player__exit-button {\n position: absolute;\n top: -22px; /* Position it above the player */\n right: 0;\n width: 20px;\n height: 20px;\n background-color: var(--sf-button-bg);\n border-radius: var(--sf-border-radius-circle);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n z-index: var(--sf-z-index-controls);\n color: white;\n border: none;\n font-size: var(--sf-font-size-md);\n transition: all var(--sf-transition-normal);\n text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n box-shadow: 0 5px 15px rgba(0, 0, 0, 0.35);\n}\n\n/* Exit button hover state */\n.sf-player__exit-button:hover {\n transform: scale(1.1);\n background-color: var(--sf-button-hover-bg);\n}\n\n/* Show exit button on minimized player hover */\n.sf-player--minimized:hover .sf-player__exit-button {\n opacity: 1;\n}\n\n/* Saltfish logo */\n.sf-player__logo {\n position: absolute;\n bottom: var(--sf-spacing-xs);\n left: 50%;\n transform: translateX(-50%);\n width: 41px;\n height: 15px;\n z-index: var(--sf-z-index-controls);\n opacity: 0.7;\n transition: opacity var(--sf-transition-normal);\n cursor: pointer;\n}\n\n/* Logo hover state */\n.sf-player:hover .sf-player__logo {\n opacity: 0.9;\n}\n\n/* Hide logo when minimized */\n.sf-player--minimized .sf-player__logo {\n display: none;\n} ";
const componentsVideoCss = "/* \n * Video component styles for the Saltfish playlist Player\n * Following BEM naming convention\n */\n\n/* Video container */\n.sf-video-container {\n position: relative;\n width: 100%;\n height: 100%;\n border-radius: var(--sf-border-radius-md);\n overflow: hidden;\n pointer-events: auto; /* Ensure clicks on video container are captured */\n}\n\n/* Video element */\n.sf-video-container__video {\n width: 100%;\n height: 100%;\n object-fit: cover;\n /* Ensure video is visible on mobile */\n display: block;\n background-color: black;\n /* Add explicit positioning to ensure video is visible */\n position: relative;\n z-index: 1;\n}\n\n\n\n/* Mobile-specific video styles */\n@media (max-width: 768px) {\n \n .sf-video-container__video {\n /* Force video dimensions on mobile */\n width: 100% !important;\n height: 100% !important;\n object-fit: cover !important;\n /* Prevent video from being hidden */\n opacity: 1 !important;\n visibility: visible !important;\n /* Ensure video is above any potential overlays */\n z-index: 10 !important;\n position: relative !important;\n /* Ensure minimum dimensions */\n min-width: 100px !important;\n min-height: 100px !important;\n }\n \n /* Make controls more touch-friendly on mobile */\n .sf-video-container__controls {\n height: 5px; /* Thicker on mobile for easier touch */\n }\n \n .sf-video-container__mute-button {\n /* Use CSS variable for consistent sizing */\n min-width: var(--sf-mute-button-size) !important;\n min-height: var(--sf-mute-button-size) !important;\n opacity: 1; /* Always visible on mobile (no hover) */\n }\n \n .sf-video-container__cc-button {\n /* Use CSS variable for consistent sizing */\n min-width: var(--sf-cc-button-size) !important;\n min-height: var(--sf-cc-button-size) !important;\n opacity: 1; /* Always visible on mobile (no hover) */\n }\n}\n\n/* Touch device specific styles */\n@media (pointer: coarse) {\n .sf-video-container__controls:hover {\n height: 5px; /* Keep consistent height on touch devices */\n }\n \n .sf-video-container__mute-button {\n opacity: 1; /* Always show on touch devices */\n }\n \n .sf-video-container__cc-button {\n opacity: 1; /* Always show on touch devices */\n }\n \n .sf-video-container:hover .sf-video-container__mute-button {\n opacity: 1;\n }\n}\n\n/* Video in minimized state */\n.sf-player--minimized .sf-video-container {\n border-radius: var(--sf-border-radius-circle);\n cursor: pointer;\n z-index: var(--sf-z-index-base);\n width: 100%;\n height: 100%;\n}\n\n.sf-player--minimized .sf-video-container__video {\n border-radius: var(--sf-border-radius-circle);\n object-fit: cover;\n width: 100%;\n height: 100%;\n}\n\n/* Hide progress bar in minimized state */\n.sf-player--minimized .sf-video-container__controls {\n display: none !important;\n}\n\n/* Also hide mute button in minimized state */\n.sf-player--minimized .sf-video-container__mute-button {\n display: none !important;\n}\n\n/* Also hide CC button in minimized state */\n.sf-player--minimized .sf-video-container__cc-button {\n display: none !important;\n}\n\n/* Progress bar container */\n.sf-video-container__controls {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 3px;\n background-color: rgba(255, 255, 255, 0.25);\n z-index: var(--sf-z-index-controls);\n border-radius: var(--sf-border-radius-md) var(--sf-border-radius-md) 0 0;\n cursor: pointer;\n}\n\n/* Show slightly thicker progress bar on hover for better UX */\n.sf-video-container__controls:hover {\n height: 5px;\n}\n\n/* Progress indicator */\n.sf-video-container__progress {\n height: 100%;\n background: rgba(255, 255, 255, 0.8);\n width: 0%;\n transition: width 0.1s linear;\n border-radius: var(--sf-border-radius-md) 0 0 0;\n cursor: pointer;\n transform-origin: left;\n}\n\n/* Mute button */\n.sf-video-container__mute-button {\n position: absolute;\n top: calc(var(--sf-spacing-xl) + var(--sf-spacing-xl));\n right: var(--sf-spacing-xs);\n width: var(--sf-mute-button-size);\n height: var(--sf-mute-button-size);\n background-color: transparent;\n border-radius: var(--sf-border-radius-circle);\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n z-index: var(--sf-z-index-controls);\n color: white;\n border: none;\n font-size: calc(var(--sf-font-size-md) + 2px);\n transition: all var(--sf-transition-normal);\n text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n opacity: 0;\n}\n\n/* Mute button hover state */\n.sf-video-container__mute-button:hover {\n transform: scale(1.1);\n}\n\n/* Show mute button on container hover */\n.sf-video-container:hover .sf-video-container__mute-button {\n opacity: 1;\n}\n\n/* CC button */\n.sf-video-container__cc-button {\n position: absolute;\n top: calc(var(--sf-spacing-xl) + var(--sf-spacing-xl) + var(--sf-mute-button-size) + var(--sf-spacing-xs));\n right: var(--sf-spacing-xs);\n width: var(--sf-cc-button-size);\n height: var(--sf-cc-button-size);\n background-color: transparent;\n border-radius: 50%; /* Ensure perfect circle */\n padding: 0; /* Remove default button padding */\n display: flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n z-index: var(--sf-z-index-controls);\n color: white;\n border: none;\n font-size: calc(var(--sf-font-size-md) + 2px);\n transition: all var(--sf-transition-normal);\n text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n opacity: 0;\n}\n\n.sf-video-container__cc-button svg {\n display: block;\n margin: auto;\n width: 60%;\n height: 60%;\n}\n\n/* CC button hover state */\n.sf-video-container__cc-button:hover {\n transform: scale(1.1);\n}\n\n/* Hide mute and cc buttons in autoplayBlocked state */\n.sf-player--autoplayBlocked .sf-video-container__mute-button,\n.sf-player--autoplayBlocked .sf-video-container__cc-button {\n display: none !important;\n}\n\n/* Show mute and cc buttons on hover in playing/paused states */\n.sf-player--playing .sf-video-container:hover .sf-video-container__mute-button,\n.sf-player--playing .sf-video-container:hover .sf-video-container__cc-button,\n.sf-player--paused .sf-video-container:hover .sf-video-container__mute-button,\n.sf-player--paused .sf-video-container:hover .sf-video-container__cc-button {\n opacity: 1;\n} ";
const componentsControlsCss = "/* \n * Controls component styles for the Saltfish playlist Player\n * Following BEM naming convention\n */\n\n/* Main controls container */\n.sf-controls-container {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n display: flex;\n justify-content: center;\n align-items: center;\n background-color: transparent;\n z-index: var(--sf-z-index-controls);\n pointer-events: auto;\n}\n\n/* Play button */\n.sf-controls-container__play-button {\n background-color: rgba(0, 0, 0, 0.6);\n border: none;\n color: var(--sf-text-color);\n font-size: var(--sf-control-button-size);\n cursor: pointer;\n width: var(--sf-play-button-size);\n height: var(--sf-play-button-size);\n border-radius: var(--sf-border-radius-circle);\n display: flex;\n justify-content: center;\n align-items: center;\n transition: transform var(--sf-transition-normal), background-color var(--sf-transition-normal);\n padding-left: 4px; /* Optical centering for play icon */\n}\n\n/* Button hover state */\n.sf-controls-container__play-button:hover {\n background-color: rgba(0, 0, 0, 0.4);\n transform: scale(1.1);\n}\n\n/* Button container for interactive buttons */\n.sf-controls-container__buttons {\n display: flex;\n justify-content: center;\n align-items: center;\n gap: var(--sf-spacing-md);\n}\n\n/* Interactive button */\n.sf-controls-container__interactive-button {\n background-color: rgba(255, 255, 255, 0.2);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n color: white;\n border: none;\n border-radius: var(--sf-border-radius-md);\n padding: var(--sf-spacing-xs) var(--sf-spacing-md);\n font-size: var(--sf-font-size-sm);\n cursor: pointer;\n transition: background-color var(--sf-transition-normal), transform var(--sf-transition-fast);\n box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);\n}\n\n.sf-controls-container__interactive-button:hover {\n background-color: rgba(255, 255, 255, 0.3);\n transform: translateY(-2px);\n}\n\n/* \n * Choice buttons container and buttons - positioned inside player\n */\n\n/* Choice buttons container - positioned inside the player at the bottom */\n.sf-choice-buttons-container {\n position: absolute;\n bottom: var(--sf-spacing-xl);\n left: 50%;\n transform: translateX(-50%);\n width: calc(100% - var(--sf-spacing-lg));\n max-width: calc(100% - var(--sf-spacing-lg));\n z-index: calc(var(--sf-z-index-controls) + 1);\n display: flex;\n flex-direction: column;\n gap: var(--sf-spacing-sm);\n pointer-events: auto;\n justify-content: flex-end;\n align-items: center;\n}\n\n/* Choice button styles - solid rounded buttons matching the image */\n.sf-choice-button {\n width: 100%;\n max-width: none;\n background: rgba(0, 0, 0, 4);\n backdrop-filter: blur(8px);\n -webkit-backdrop-filter: blur(8px);\n color: white;\n border: none;\n border-radius: 24px; /* More rounded for pill shape */\n padding: var(--sf-spacing-md) var(--sf-spacing-md);\n font-size: 12px;\n cursor: pointer;\n transition: all 0.2s ease;\n text-align: center;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n outline: none;\n font-family: inherit;\n margin-bottom: 0;\n position: relative;\n overflow: hidden;\n}\n\n/* Hover state for buttons */\n.sf-choice-button:hover {\n background: rgba(0, 0, 0, 0.9);\n transform: translateY(-2px);\n box-shadow: 0 6px 16px rgba(0, 0, 0, 0.8);\n}\n\n/* Active state for all buttons */\n.sf-choice-button:active {\n transform: translateY(0) scale(0.98);\n transition: all 0.1s ease;\n}\n\n/* Remove specific action type styling - use consistent dark buttons */\n.sf-choice-button--goto,\n.sf-choice-button--url,\n.sf-choice-button--next,\n.sf-choice-button--dom,\n.sf-choice-button--function {\n background: rgba(0, 0, 0, 0.4);\n border: none;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n}\n\n.sf-choice-button--goto:hover,\n.sf-choice-button--url:hover,\n.sf-choice-button--next:hover,\n.sf-choice-button--dom:hover,\n.sf-choice-button--function:hover {\n background: rgba(0, 0, 0, 0.9);\n transform: translateY(-2px);\n box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);\n}\n\n/* Hide choice buttons in minimized state */\n.sf-player--minimized .sf-choice-buttons-container {\n display: none;\n}\n\n/* Smaller mobile screens - maintain same layout but with tighter spacing */\n@media (max-width: 480px) {\n .sf-choice-buttons-container {\n bottom: var(--sf-spacing-sm);\n width: calc(100% - var(--sf-spacing-md));\n max-width: calc(100% - var(--sf-spacing-md));\n gap: calc(var(--sf-spacing-xs) + 2px);\n }\n \n .sf-choice-button {\n padding: var(--sf-spacing-sm) var(--sf-spacing-sm);\n font-size: 11px;\n border-radius: 20px;\n }\n}\n\n/* Touch device specific adjustments */\n@media (pointer: coarse) {\n .sf-choice-buttons-container {\n gap: var(--sf-spacing-sm);\n }\n \n .sf-choice-button {\n min-height: 44px; /* Apple's recommended minimum touch target size */\n padding: var(--sf-spacing-md) var(--sf-spacing-md);\n }\n} ";
const componentsTranscriptCss = "/* \n * Transcript component styles for the Saltfish Playlist Player\n * Following BEM naming convention\n */\n\n/* Transcript container */\n.sf-transcript {\n position: absolute;\n bottom: 40px; /* Above the progress bar */\n left: 0;\n right: 0;\n max-height: 200px;\n background: rgba(0, 0, 0, 0.7);\n backdrop-filter: blur(8px);\n margin: 0 var(--sf-spacing-md);\n overflow: hidden;\n z-index: var(--sf-z-index-overlay);\n opacity: 0;\n transform: translateY(20px);\n transition: all 0.3s ease-out;\n pointer-events: none;\n}\n\n/* Visible state */\n.sf-transcript--visible {\n opacity: 1;\n transform: translateY(0);\n pointer-events: auto;\n}\n\n/* Transcript content */\n.sf-transcript__content {\n max-height: 180px;\n overflow-y: auto;\n padding: var(--sf-spacing-xs);\n scrollbar-width: thin;\n scrollbar-color: rgba(255, 255, 255, 0.3) transparent;\n}\n\n/* Webkit scrollbar styling */\n.sf-transcript__content::-webkit-scrollbar {\n width: 4px;\n}\n\n.sf-transcript__content::-webkit-scrollbar-track {\n background: transparent;\n}\n\n.sf-transcript__content::-webkit-scrollbar-thumb {\n background: rgba(255, 255, 255, 0.3);\n border-radius: 2px;\n}\n\n.sf-transcript__content::-webkit-scrollbar-thumb:hover {\n background: rgba(255, 255, 255, 0.5);\n}\n\n/* Transcript segments */\n.sf-transcript__segment {\n color: rgba(255, 255, 255);\n font-size: var(--sf-font-size-sm);\n line-height: 1.4;\n padding: var(--sf-spacing-xs) 0;\n cursor: pointer;\n transition: all 0.2s ease;\n padding-left: var(--sf-spacing-xs);\n padding-right: var(--sf-spacing-xs);\n}\n\n/* CC button active state */\n.sf-video-container__cc-button--active {\n background: rgba(255, 255, 255, 0.2);\n color: #fff;\n}\n\n/* Mobile responsive styles */\n@media (max-width: 768px) {\n .sf-transcript {\n bottom: 50px; /* More space on mobile */\n margin: 0 var(--sf-spacing-sm);\n max-height: 150px; /* Smaller on mobile */\n }\n \n .sf-transcript__content {\n max-height: 130px;\n padding: var(--sf-spacing-sm);\n }\n \n .sf-transcript__segment {\n font-size: var(--sf-font-size-xs);\n padding: var(--sf-spacing-xs) var(--sf-spacing-sm);\n }\n}\n\n/* Touch device optimizations */\n@media (pointer: coarse) {\n .sf-transcript__segment {\n padding: var(--sf-spacing-sm) var(--sf-spacing-xs);\n min-height: 44px; /* Larger touch target */\n display: flex;\n align-items: center;\n }\n}\n\n/* Hide transcript in minimized state */\n.sf-player--minimized .sf-transcript {\n display: none !important;\n}\n\n/* Animation for transcript appearance */\n@keyframes transcriptFadeIn {\n from {\n opacity: 0;\n transform: translateY(20px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes transcriptFadeOut {\n from {\n opacity: 1;\n transform: translateY(0);\n }\n to {\n opacity: 0;\n transform: translateY(20px);\n }\n}";
const animationsTransitionsCss = "/* \n * Transitions and animations for Saltfish playlist Player\n */\n\n/* Fade in animation */\n@keyframes sf-fade-in {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n\n.sf-fade-in {\n animation: sf-fade-in 0.3s ease-in-out forwards;\n}\n\n/* Slide in from bottom animation */\n@keyframes sf-slide-in-bottom {\n from { transform: translateY(100%); opacity: 0; }\n to { transform: translateY(0); opacity: 1; }\n}\n\n.sf-slide-in-bottom {\n animation: sf-slide-in-bottom 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;\n}\n\n/* Slide in from right animation */\n@keyframes sf-slide-in-right {\n from { transform: translateX(100%); opacity: 0; }\n to { transform: translateX(0); opacity: 1; }\n}\n\n.sf-slide-in-right {\n animation: sf-slide-in-right 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;\n}\n\n/* Scale in animation */\n@keyframes sf-scale-in {\n from { transform: scale(0.8); opacity: 0; }\n to { transform: scale(1); opacity: 1; }\n}\n\n.sf-scale-in {\n animation: sf-scale-in 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;\n}\n\n/* Scale out animation */\n@keyframes sf-scale-out {\n from { transform: scale(1); opacity: 1; }\n to { transform: scale(0.8); opacity: 0; }\n}\n\n.sf-scale-out {\n animation: sf-scale-out 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;\n} ";
const getPlayerStyles = () => `
${baseResetCss}
${baseVariablesCss}
${componentsPlayerCss}
${componentsVideoCss}
${componentsControlsCss}
${componentsTranscriptCss}
${animationsTransitionsCss}
`;
class ShadowDOMManager {
constructor() {
__publicField(this, "container", null);
__publicField(this, "shadowRoot", null);
__publicField(this, "styleElement", null);
}
/**
* Creates a new shadow DOM container on the page
*/
create() {
if (this.container) {
return;
}
this.container = document.createElement("div");
this.container.id = "saltfish-container";
document.body.appendChild(this.container);
this.shadowRoot = this.container.attachShadow({ mode: "open" });
this.styleElement = document.createElement("style");
this.styleElement.textContent = this.getBaseStyles();
this.shadowRoot.appendChild(this.styleElement);
const rootElement = document.createElement("div");
rootElement.id = "sf-player-root";
this.shadowRoot.appendChild(rootElement);
}
/**
* Returns the shadow root
*/
getShadowRoot() {
return this.shadowRoot;
}
/**
* Returns the root element inside the shadow DOM
*/
getRootElement() {
if (!this.shadowRoot) {
return null;
}
const rootElement = this.shadowRoot.getElementById("sf-player-root");
return rootElement;
}
/**
* Adds a stylesheet to the shadow DOM
*/
addStyles(styles) {
if (!this.styleElement) return;
this.styleElement.textContent += styles;
}
/**
* Removes the shadow DOM container
*/
remove() {
if (this.container) {
document.body.removeChild(this.container);
this.container = null;
this.shadowRoot = null;
this.styleElement = null;
}
}
/**
* Returns base styles for the shadow DOM
* Now using our organized CSS structure imported from styles/index.ts
*/
getBaseStyles() {
return getPlayerStyles();
}
}
const __vite_import_meta_env__ = {};
const createStoreImpl = (createState) => {
let state;
const listeners = /* @__PURE__ */ new Set();
const setState = (partial, replace) => {
const nextState = typeof partial === "function" ? partial(state) : partial;
if (!Object.is(nextState, state)) {
const previousState = state;
state = (replace != null ? replace : typeof nextState !== "object" || nextState === null) ? nextState : Object.assign({}, state, nextState);
listeners.forEach((listener) => listener(state, previousState));
}
};
const getState = () => state;
const getInitialState = () => initialState;
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const destroy = () => {
if ((__vite_import_meta_env__ ? "production" : void 0) !== "production") {
console.warn(
"[DEPRECATED] The `destroy` method will be unsupported in a future version. Instead use unsubscribe function returned by subscribe. Everything will be garbage-collected if store is garbage-collected."
);
}
listeners.clear();
};
const api = { setState, getState, getInitialState, subscribe, destroy };
const initialState = state = createState(setState, getState, api);
return api;
};
const createStore = (createState) => createStoreImpl;
var NOTHING = Symbol.for("immer-nothing");
var DRAFTABLE = Symbol.for("immer-draftable");
var DRAFT_STATE = Symbol.for("immer-state");
function die(error2, ...args) {
throw new Error(
`[Immer] minified error nr: ${error2}. Full error at: https://bit.ly/3cXEKWf`
);
}
var getPrototypeOf = Object.getPrototypeOf;
function isDraft(value) {
return !!value && !!value[DRAFT_STATE];
}
function isDraftable(value) {
var _a;
if (!value)
return false;
return isPlainObject(value) || Array.isArray(value) || !!value[DRAFTABLE] || !!((_a = value.constructor) == null ? void 0 : _a[DRAFTABLE]) || isMap(value) || isSet(value);
}
var objectCtorString = Object.prototype.constructor.toString();
function isPlainObject(value) {
if (!value || typeof value !== "object")
return false;
const proto = getPrototypeOf(value);
if (proto === null) {
return true;
}
const Ctor = Object.hasOwnProperty.call(proto, "constructor") && proto.constructor;
if (Ctor === Object)
return true;
return typeof Ctor == "function" && Function.toString.call(Ctor) === objectCtorString;
}
function each(obj, iter) {
if (getArchtype(obj) === 0) {
Reflect.ownKeys(obj).forEach((key) => {
iter(key, obj[key], obj);
});
} else {
obj.forEach((entry, index) => iter(index, entry, obj));
}
}
function getArchtype(thing) {
const state = thing[DRAFT_STATE];
return state ? state.type_ : Array.isArray(thing) ? 1 : isMap(thing) ? 2 : isSet(thing) ? 3 : 0;
}
function has(thing, prop) {
return getArchtype(thing) === 2 ? thing.has(prop) : Object.prototype.hasOwnProperty.call(thing, prop);
}
function set(thing, propOrOldValue, value) {
const t = getArchtype(thing);
if (t === 2)
thing.set(propOrOldValue, value);
else if (t === 3) {
thing.add(value);
} else
thing[propOrOldValue] = value;
}
function is(x, y) {
if (x === y) {
return x !== 0 || 1 / x === 1 / y;
} else {
return x !== x && y !== y;
}
}
function isMap(target) {
return target instanceof Map;
}
function isSet(target) {
return target instanceof Set;
}
function latest(state) {
return state.copy_ || state.base_;
}
function shallowCopy(base, strict) {
if (isMap(base)) {
return new Map(base);
}
if (isSet(base)) {
return new Set(base);
}
if (Array.isArray(base))
return Array.prototype.slice.call(base);
const isPlain = isPlainObject(base);
if (strict === true || strict === "class_only" && !isPlain) {
const descriptors = Object.getOwnPropertyDescriptors(base);
delete descriptors[DRAFT_STATE];
let keys = Reflect.ownKeys(descriptors);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const desc = descriptors[key];
if (desc.writable === false) {
desc.writable = true;
desc.configurable = true;
}
if (desc.get || desc.set)
descriptors[key] = {
configurable: true,
writable: true,
// could live with !!desc.set as well here...
enumerable: desc.enumerable,
value: base[key]
};
}
return Object.create(getPrototypeOf(base), descriptors);
} else {
const proto = getPrototypeOf(base);
if (proto !== null && isPlain) {
return { ...base };
}
const obj = Object.create(proto);
return Object.assign(obj, base);
}
}
function freeze(obj, deep = false) {
if (isFrozen(obj) || isDraft(obj) || !isDraftable(obj))
return obj;
if (getArchtype(obj) > 1) {
obj.set = obj.add = obj.clear = obj.delete = dontMutateFrozenCollections;
}
Object.freeze(obj);
if (deep)
Object.entries(obj).forEach(([key, value]) => freeze(value, true));
return obj;
}
function dontMutateFrozenCollections() {
die(2);
}
function isFrozen(obj) {
return Object.isFrozen(obj);
}
var plugins = {};
function getPlugin(pluginKey) {
const plugin = plugins[pluginKey];
if (!plugin) {
die(0, pluginKey);
}
return plugin;
}
var currentScope;
function getCurrentScope() {
return currentScope;
}
function createScope(parent_, immer_) {
return {
drafts_: [],
parent_,
immer_,
// Whenever the modified draft contains a draft from another scope, we
// need to prevent auto-freezing so the unowned draft can be finalized.
canAutoFreeze_: true,
unfinalizedDrafts_: 0
};
}
function usePatchesInScope(scope, patchListener) {
if (patchListener) {
getPlugin("Patches");
scope.patches_ = [];
scope.inversePatches_ = [];
scope.patchListener_ = patchListener;
}
}
function revokeScope(scope) {
leaveScope(scope);
scope.drafts_.forEach(revokeDraft);
scope.drafts_ = null;
}
function leaveScope(scope) {
if (scope === currentScope) {
currentScope = scope.parent_;
}
}
function enterScope(immer2) {
return currentScope = createScope(currentScope, immer2);
}
function revokeDraft(draft) {
const state = draft[DRAFT_STATE];
if (state.type_ === 0 || state.type_ === 1)
state.revoke_();
else
state.revoked_ = true;
}
function processResult(result, scope) {
scope.unfinalizedDrafts_ = scope.drafts_.length;
const baseDraft = scope.drafts_[0];
const isReplaced = result !== void 0 && result !== baseDraft;
if (isReplaced) {
if (baseDraft[DRAFT_STATE].modified_) {
revokeScope(scope);
die(4);
}
if (isDraftable(result)) {
result = finalize(scope, result);
if (!scope.parent_)
maybeFreeze(scope, result);
}
if (scope.patches_) {
getPlugin("Patches").generateReplacementPatches_(
baseDraft[DRAFT_STATE].base_,
result,
scope.patches_,
scope.inversePatches_
);
}
} else {
result = finalize(scope, baseDraft, []);
}
revokeScope(scope);
if (scope.patches_) {
scope.patchListener_(scope.patches_, scope.inversePatches_);
}
return result !== NOTHING ? result : void 0;
}
function finalize(rootScope, value, path) {
if (isFrozen(value))
return value;
const state = value[DRAFT_STATE];
if (!state) {
each(
value,
(key, childValue) => finalizeProperty(rootScope, state, value, key, childValue, path)
);
return value;
}
if (state.scope_ !== rootScope)
return value;
if (!state.modified_) {
maybeFreeze(rootScope, state.base_, true);
return state.base_;
}
if (!state.finalized_) {
state.finalized_ = true;
state.scope_.unfinalizedDrafts_--;
const result = state.copy_;
let resultEach = result;
let isSet2 = false;
if (state.type_ === 3) {
resultEach = new Set(result);
result.clear();
isSet2 = true;
}
each(
resultEach,
(key, childValue) => finalizeProperty(rootScope, state, result, key, childValue, path, isSet2)
);
maybeFreeze(rootScope, result, false);
if (path && rootScope.patches_) {
getPlugin("Patches").generatePatches_(
state,
path,
rootScope.patches_,
rootScope.inversePatches_
);
}
}
return state.copy_;
}
function finalizeProperty(rootScope, parentState, targetObject, prop, childValue, rootPath, targetIsSet) {
if (isDraft(childValue)) {
const path = rootPath && parentState && parentState.type_ !== 3 && // Set objects are atomic since they have no keys.
!has(parentState.assigned_, prop) ? rootPath.concat(prop) : void 0;
const res = finalize(rootScope, childValue, path);
set(targetObject, prop, res);
if (isDraft(res)) {
rootScope.canAutoFreeze_ = false;
} else
return;
} else if (targetIsSet) {
targetObject.add(childValue);
}
if (isDraftable(childValue) && !isFrozen(childValue)) {
if (!rootScope.immer_.autoFreeze_ && rootScope.unfinalizedDrafts_ < 1) {
return;
}
finalize(rootScope, childValue);
if ((!parentState || !parentState.scope_.parent_) && typeof prop !== "symbol" && Object.prototype.propertyIsEnumerable.call(targetObject, prop))
maybeFreeze(rootScope, childValue);
}
}
function maybeFreeze(scope, value, deep = false) {
if (!scope.parent_ && scope.immer_.autoFreeze_ && scope.canAutoFreeze_) {
freeze(value, deep);
}
}
function createProxyProxy(base, parent) {
const isArray = Array.isArray(base);
const state = {
type_: isArray ? 1 : 0,
// Track which produce call this is associated with.
scope_: parent ? parent.scope_ : getCurrentScope(),
// True for both shallow and deep changes.
modified_: false,
// Used during finalization.
finalized_: false,
// Track which properties have been assigned (true) or deleted (false).
assigned_: {},
// The parent draft state.
parent_: parent,
// The base state.
base_: base,
// The base proxy.
draft_: null,
// set below
// The base copy with any updated values.
copy_: null,
// Called by the `produce` function.
revoke_: null,
isManual_: false
};
let target = state;
let traps = objectTraps;
if (isArray) {
target = [state];
traps = arrayTraps;
}
const { revoke, proxy } = Proxy.revocable(target, traps);
state.draft_ = proxy;
state.revoke_ = revoke;
return proxy;
}
var objectTraps = {
get(state, prop) {
if (prop === DRAFT_STATE)
return state;
const source = latest(state);
if (!has(source, prop)) {
return readPropFromProto(state, source, prop);
}
const value = source[prop];
if (state.finalized_ || !isDraftable(value)) {
return value;
}
if (value === peek(state.base_, prop)) {
prepareCopy(state);
return state.copy_[prop] = createProxy(value, state);
}
return value;
},
has(state, prop) {
return prop in latest(state);
},
ownKeys(state) {
return Reflect.ownKeys(latest(state));
},
set(state, prop, value) {
const desc = getDescriptorFromProto(latest(state), prop);
if (desc == null ? void 0 : desc.set) {
desc.set.call(state.draft_, value);
return true;
}
if (!state.modified_) {
const current2 = peek(latest(state), prop);
const currentState = current2 == null ? void 0 : current2[DRAFT_STATE];
if (currentState && currentState.base_ === value) {
state.copy_[prop] = value;
state.assigned_[prop] = false;
return true;
}
if (is(value, current2) && (value !== void 0 || has(state.base_, prop)))
return true;
prepareCopy(state);
markChanged(state);
}
if (state.copy_[prop] === value && // special case: handle new props with value 'undefined'
(value !== void 0 || prop in state.copy_) || // special case: NaN
Number.isNaN(value) && Number.isNaN(state.copy_[prop]))
return true;
state.copy_[prop] = value;
state.assigned_[prop] = true;
return true;
},
deleteProperty(state, prop) {
if (peek(state.base_, prop) !== void 0 || prop in state.base_) {
state.assigned_[prop] = false;
prepareCopy(state);
markChanged(state);
} else {
delete state.assigned_[prop];
}
if (state.copy_) {
delete state.copy_[prop];
}
return true;
},
// Note: We never coerce `desc.value` into an Immer draft, because we can't make
// the same guarantee in ES5 mode.
getOwnPropertyDescriptor(state, prop) {
const owner = latest(state);
const desc = Reflect.getOwnPropertyDescriptor(owner, prop);
if (!desc)
return desc;
return {
writable: true,
configurable: state.type_ !== 1 || prop !== "length",
enumerable: desc.enumerable,
value: owner[prop]
};
},
defineProperty() {
die(11);
},
getPrototypeOf(state) {
return getPrototypeOf(state.base_);
},
setPrototypeOf() {
die(12);
}
};
var arrayTraps = {};
each(objectTraps, (key, fn) => {
arrayTraps[key] = function() {
arguments[0] = arguments[0][0];
return fn.apply(this, arguments);
};
});
arrayTraps.deleteProperty = function(state, prop) {
return arrayTraps.set.call(this, state, prop, void 0);
};
arrayTraps.set = function(state, prop, value) {
return objectTraps.set.call(this, state[0], prop, value, state[0]);
};
function peek(draft, prop) {
const state = draft[DRAFT_STATE];
const source = state ? latest(state) : draft;
return source[prop];
}
function readPropFromProto(state, source, prop) {
var _a;
const desc = getDescriptorFromProto(source, prop);
return desc ? `value` in desc ? desc.value : (
// This is a very special case, if the prop is a getter defined by the
// prototype, we should invoke it with the draft as context!
(_a = desc.get) == null ? void 0 : _a.call(state.draft_)
) : void 0;
}
function getDescriptorFromProto(source, prop) {
if (!(prop in source))
return void 0;
let proto = getPrototypeOf(source);
while (proto) {
const desc = Object.getOwnPropertyDescriptor(proto, prop);
if (desc)
return desc;
proto = getPrototypeOf(proto);
}
return void 0;
}
function markChanged(state) {
if (!state.modified_) {
state.modified_ = true;
if (state.parent_) {
markChanged(state.parent_);
}
}
}
function prepareCopy(state) {
if (!state.copy_) {
state.copy_ = shallowCopy(
state.base_,
state.scope_.immer_.useStrictShallowCopy_
);
}
}
var Immer2 = class {
constructor(config) {
this.autoFreeze_ = true;
this.useStrictShallowCopy_ = false;
this.produce = (base, recipe, patchListener) => {
if (typeof base === "function" && typeof recipe !== "function") {
const defaultBase = recipe;
recipe = base;
const self = this;
return function curriedProduce(base2 = defaultBase, ...args) {
return self.produce(base2, (draft) => recipe.call(this, draft, ...args));
};
}
if (typeof recipe !== "function")
die(6);
if (patchListener !== void 0 && typeof patchListener !== "function")
die(7);
let result;
if (isDraftable(base)) {
const scope = enterScope(this);
const proxy = createProxy(base, void 0);
let hasError = true;
try {
result = recipe(proxy);
hasError = false;
} finally {
if (hasError)
revokeScope(scope);
else
leaveScope(scope);
}
usePatchesInScope(scope, patchListener);
return processResult(result, scope);
} else if (!base || typeof base !== "object") {
result = recipe(base);
if (result === void 0)
result = base;
if (result === NOTHING)
result = void 0;
if (this.autoFreeze_)
freeze(result, true);
if (patchListener) {
const p = [];
const ip = [];
getPlugin("Patches").generateReplacementPatches_(base, result, p, ip);
patchListener(p, ip);
}
return result;
} else
die(1, base);
};
this.produceWithPatches = (base, recipe) => {
if (typeof base === "function") {
return (state, ...args) => this.produceWithPatches(state, (draft) => base(draft, ...args));
}
let patches, inversePatches;
const result = this.produce(base, recipe, (p, ip) => {
patches = p;
inversePatches = ip;
});
return [result, patches, inversePatches];
};
if (typeof (config == null ? void 0 : config.autoFreeze) === "boolean")
this.setAutoFreeze(config.autoFreeze);
if (typeof (config == null ? void 0 : config.useStrictShallowCopy) === "boolean")
this.setUseStrictShallowCopy(config.useStrictShallowCopy);
}
createDraft(base) {
if (!isDraftable(base))
die(8);
if (isDraft(base))
base = current(base);
const scope = enterScope(this);
const proxy = createProxy(base, void 0);
proxy[DRAFT_STATE].isManual_ = true;
leaveScope(scope);
return proxy;
}
finishDraft(draft, patchListener) {
const state = draft && draft[DRAFT_STATE];
if (!state || !state.isManual_)
die(9);
const { scope_: scope } = state;
usePatchesInScope(scope, patchListener);
return processResult(void 0, scope);
}
/**
* Pass true to automatically freeze all copies created by Immer.
*
* By default, auto-freezing is enabled.
*/
setAutoFreeze(value) {
this.autoFreeze_ = value;
}
/**
* Pass true to enable strict shallow copy.
*
* By default, immer does not copy the object descriptors such as getter, setter and non-enumrable properties.
*/
setUseStrictShallowCopy(value) {
this.useStrictShallowCopy_ = value;
}
applyPatches(base, patches) {
let i;
for (i = patches.length - 1; i >= 0; i--) {
const patch = patches[i];
if (patch.path.length === 0 && patch.op === "replace") {
base = patch.value;
break;
}
}
if (i > -1) {
patches = patches.slice(i + 1);
}
const applyPatchesImpl = getPlugin("Patches").applyPatches_;
if (isDraft(base)) {
return applyPatchesImpl(base, patches);
}
return this.produce(
base,
(draft) => applyPatchesImpl(draft, patches)
);
}
};
function createProxy(value, parent) {
const draft = isMap(value) ? getPlugin("MapSet").proxyMap_(value, parent) : isSet(value) ? getPlugin("MapSet").proxySet_(value, parent) : createProxyProxy(value, parent);
const scope = parent ? parent.scope_ : getCurrentScope();
scope.drafts_.push(draft);
return draft;
}
function current(value) {
if (!isDraft(value))
die(10, value);
return currentImpl(value);
}
function currentImpl(value) {
if (!isDraftable(value) || isFrozen(value))
return value;
const state = value[DRAFT_STATE];
let copy;
if (state) {
if (!state.modified_)
return state.base_;
state.finalized_ = true;
copy = shallowCopy(value, state.scope_.immer_.useStrictShallowCopy_);
} else {
copy = shallowCopy(value, true);
}
each(copy, (key, childValue) => {
set(copy, key, currentImpl(childValue));
});
if (state) {
state.finalized_ = false;
}
return copy;
}
var immer$1 = new Immer2();
var produce = immer$1.produce;
immer$1.produceWithPatches.bind(
immer$1
);
immer$1.setAutoFreeze.bind(immer$1);
immer$1.setUseStrictShallowCopy.bind(immer$1);
immer$1.applyPatches.bind(immer$1);
immer$1.createDraft.bind(immer$1);
immer$1.finishDraft.bind(immer$1);
const immerImpl = (initializer) => (set2, get, store) => {
store.setState = (updater, replace, ...a) => {
const nextState = typeof updater === "function" ? produce(updater) : updater;
return set2(nextState, replace, ...a);
};
return initializer(store.setState, get, store);
};
const immer = immerImpl;
const POSITION_KEYWORDS = {
BOTTOM: "bottom",
LEFT: "left",
RIGHT: "right",
CENTER: "center"
};
const STORAGE_KEYS = {
PROGRESS: "saltfish_progress",
SESSION: "saltfish_session",
ANONYMOUS_USER: "saltfish_anonymous_user_data"
};
const DIMENSIONS = {
// Margins
DEFAULT_MARGIN: 20,
// Transform values
TRANSFORM_CENTER_X: "-50%",
TRANSFORM_CENTER_Y: "-50%",
TRANSFORM_BOTTOM: "-100%"
};
const TIMING = {
// Drag behavior
DRAG_THRESHOLD_PX: 5,
DRAG_RESET_DELAY: 300,
// Polling and updates
VIDEO_PROGRESS_POLL_INTERVAL: 50,
CURSOR_UPDATE_THROTTLE: 100,
// Analytics
ANALYTICS_FLUSH_INTERVAL: 3e4,
// 30 seconds
// Timeouts
USER_DATA_TIMEOUT: 5e3,
// 5 seconds
STEP_TIMEOUT: 6e4,
// 60 seconds - Player will be destroyed if user stays on same step
// Default animation distance
CURSOR_DEFAULT_DISTANCE: 100,
// Session persistence
SESSION_EXPIRY: 30 * 60 * 1e3
// 30 minutes in milliseconds
};
const API = {
BASE_URL: "https://player.saltfish.ai",
ENDPOINTS: {
VALIDATE_TOKEN: "/validate-token",
USERS: "/clients/{token}/users/{userId}"
}
};
const CSS_CLASSES = {
PLAYER: "sf-player",
PLAYER_MINIMIZED: "sf-player--minimized",
CONTROLS_CONTAINER: "sf-controls-container",
LOGO: "sf-player__logo"
};
function log(message, data) {
}
function info(message, data) {
if (data !== void 0) {
console.info(message, data);
} else {
console.info(message);
}
}
function warn(message, data) {
{
console.warn(message);
}
}
function error(message, data) {
if (data !== void 0) {
console.error(message, data);
} else {
console.error(message);
}
}
function debug(message, data) {
}
class PlayerStateMachine {
constructor(config, initialContext) {
__publicField(this, "currentState");
__publicField(this, "config");
__publicField(this, "context");
__publicField(this, "actionHandlers", {});
this.config = config;
this.currentState = config.initial;
this.context = initialContext;
this.setupDefaultActions();
this.runEntryActions(this.currentState);
}
/**
* Set up default action handlers for common operations
*/
setupDefaultActions() {
this.actionHandlers = {
...this.actionHandlers,
logStateEntry: (_context) => {
log(`State Machine: Entered ${this.currentState} state`);
},
logErrorEvent: (_context, event) => {
if ((event == null ? void 0 : event.type) === "ERROR") {
log(`State Machine: ERROR event received with message: ${event.error.message}`);
}
},
logStepTransition: (_context, event) => {
if ((event == null ? void 0 : event.type) === "TRANSITION_TO_STEP") {
log(`State Machine: Transitioning to new step: ${event.step.id}`);
}
},
logErrorRecovery: () => {
}
};
}
/**
* Register custom action handlers
* @param actions - Object mapping action names to handler functions
*/
registerActions(actions) {
this.actionHandlers = { ...this.actionHandlers, ...actions };
log(`PlayerStateMachine: Registered action handlers: ${Object.keys(actions).join(", ")}`);
}
/**
* Execute an action (either named or inline function)
* @param action - The action to execute
* @param event - Optional event that triggered the action
*/
executeAction(action, event) {
if (typeof action === "string") {
const handler = this.actionHandlers[action];
if (handler) {
handler(this.context, event);
}
} else {
action(this.context, event);
}
}
/**
* Send an event to the state machine to trigger a transition
* @param event - The event to send
* @returns The new state after the transition
*/
send(event) {
const stateConfig = this.config.states[this.currentState];
const transition = stateConfig.on[event.type];
if (!transition) {
log(`No transition defined for event ${event.type} in state ${this.currentState}`);
return this.currentState;
}
log(`Processing transition: ${this.currentState} -> ${transition.target} via ${event.type}`);
this.runExitActions(this.currentState);
this.updateContextFromEvent(event);
if (transition.actions) {
transition.actions.forEach((action) => this.executeAction(action, event));
}
const prevState = this.currentState;
this.currentState = transition.target;
this.runEntryActions(this.currentState);
log(`State transition complete: ${prevState} -> ${this.currentState}`);
return this.currentState;
}
/**
* Update context based on the event
* @param event - The event to process
*/
updateContextFromEvent(event) {
if (event.type === "TRANSITION_TO_STEP" || event.type === "MANIFEST_LOADED" || event.type === "VIDEO_ENDED") {
this.context.currentStep = event.step;
} else if (event.type === "ERROR") {
this.context.error = event.error;
}
}
/**
* Get the current state of the state machine
* @returns The current state
*/
getState() {
return this.currentState;
}
/**
* Get the current context of the state machine
* @returns The current context
*/
getContext() {
return this.context;
}
/**
* Update the context directly (use with caution)
* @param updater Function that takes the current context and returns an updated one
*/
updateContext(updater) {
const updates = updater(this.context);
this.context = { ...this.context, ...updates };
}
/**
* Run entry actions for a state
* @param state - The state to run entry actions for
*/
runEntryActions(state) {
const stateConfig = this.config.states[state];
if (stateConfig.entry) {
stateConfig.entry.forEach((action) => this.executeAction(action));
}
}
/**
* Run exit actions for a state
* @param state - The state to run exit actions for
*/
runExitActions(state) {
const stateConfig = this.config.states[state];
if (stateConfig.exit) {
stateConfig.exit.forEach((action) => this.executeAction(action));
}
}
}
const playerStateMachineConfig = {
initial: "idle",
states: {
"idle": {
on: {
"INITIALIZE": { target: "idle" },
"LOAD_MANIFEST": { target: "loading" }
},
entry: ["logStateEntry"]
},
"loading": {
on: {
"MANIFEST_LOADED": { target: "paused" },
"ERROR": {
target: "error",
actions: ["logErrorEvent"]
}
},
entry: ["logStateEntry"]
},
"playing": {
on: {
"PAUSE": { target: "paused" },
"MINIMIZE": { target: "minimized" },
"VIDEO_ENDED": { target: "waitingForInteraction" },
"AUTOPLAY_FALLBACK": { target: "autoplayBlocked" },
"TRANSITION_TO_STEP": {
target: "playing",
actions: ["logStepTransition"]
},
"ERROR": { target: "error" },
"COMPLETE_PLAYLIST": { target: "completed" }
},
entry: ["logStateEntry", "startVideoPlayback"],
exit: ["pauseVideoPlayback"]
},
"paused": {
on: {
"PLAY": { target: "playing" },
"MINIMIZE": { target: "minimized" },
"TRANSITION_TO_STEP": {
target: "playing",
actions: ["logStepTransition"]
}
},
entry: ["logStateEntry", "pauseVideoPlayback"]
},
"minimized": {
on: {
"MAXIMIZE": { target: "paused" }
},
entry: ["logStateEntry", "pauseVideoPlayback"]
},
"waitingForInteraction": {
on: {
"PLAY": { target: "playing" },
"PAUSE": { target: "paused" },
"MINIMIZE": { target: "minimized" },
"COMPLETE_PLAYLIST": { target: "completed" },
"TRANSITION_TO_STEP": {
target: "playing",
actions: ["logStepTransition"]
}
},
entry: ["logStateEntry"]
},
"autoplayBlocked": {
on: {
"PLAY": { target: "playing" },
"TRANSITION_TO_STEP": { target: "playing" },
"MINIMIZE": { target: "minimized" }
},
entry: ["logStateEntry", "startMutedLoopedVideo"]
},
"error": {
on: {
"INITIALIZE": { target: "idle" },
"PLAY": {
target: "playing",
actions: ["logErrorRecovery"]
},
"AUTOPLAY_FALLBACK": { target: "autoplayBlocked" }
},
entry: ["logStateEntry", "handleError"]
},
"completed": {
on: {
"INITIALIZE": { target: "idle" }
},
entry: ["logStateEntry", "trackPlaylistComplete"]
}
}
};
const DEFAULT_PLAYER_WIDTH = 200;
const DEFAULT_MINIMIZED_WIDTH = 80;
const SCREEN_EDGE_MARGIN = 20;
const RIGHT_EDGE_MARGIN = 20;
const getStyleHost = () => {
const shadowContainer = document.getElementById("saltfish-container");
if (shadowContainer && shadowContainer.shadowRoot) {
return shadowContainer;
}
return document.documentElement;
};
const getPlayerWidth = () => {
if (typeof window !== "undefined") {
const styleHost = getStyleHost();
const computedStyle = getComputedStyle(styleHost);
const cssValue = computedStyle.getPropertyValue("--sf-player-width");
const width = parseInt(cssValue, 10) || DEFAULT_PLAYER_WIDTH;
return width;
}
return DEFAULT_PLAYER_WIDTH;
};
const getMinimizedPlayerWidth = () => {
if (typeof window !== "undefined") {
const styleHost = getStyleHost();
const computedStyle = getComputedStyle(styleHost);
const width = parseInt(computedStyle.getPropertyValue("--sf-player-min-width"), 10) || DEFAULT_MINIMIZED_WIDTH;
return width;
}
return DEFAULT_MINIMIZED_WIDTH;
};
const getPlayerDimensions = (isMinimized) => {
const width = isMinimized ? getMinimizedPlayerWidth() : getPlayerWidth();
const minX = SCREEN_EDGE_MARGIN;
const maxX = window.innerWidth - width - RIGHT_EDGE_MARGIN;
return { width, minX, maxX };
};
class PositionCalculator {
/**
* Calculates the initial position based on a position string
* @param params Position calculation parameters
* @returns Calculated position with coordinates and transforms
*/
static calculatePosition(params) {
const {
position,
viewportWidth = window.innerWidth,
viewportHeight = window.innerHeight,
playerWidth = getPlayerWidth()
} = params;
let x = DIMENSIONS.DEFAULT_MARGIN;
let y = DIMENSIONS.DEFAULT_MARGIN;
let transformX = "0";
let transformY = "0";
if (position.includes(POSITION_KEYWORDS.BOTTOM)) {
y = viewportHeight - DIMENSIONS.DEFAULT_MARGIN;
transformY = DIMENSIONS.TRANSFORM_BOTTOM;
}
if (position.includes(POSITION_KEYWORDS.LEFT)) {
x = DIMENSIONS.DEFAULT_MARGIN;
} else if (position.includes(POSITION_KEYWORDS.RIGHT)) {
x = viewportWidth - playerWidth - RIGHT_EDGE_MARGIN;
} else if (position.includes(POSITION_KEYWORDS.CENTER)) {
x = viewportWidth / 2;
y = viewportHeight / 2;
transformX = DIMENSIONS.TRANSFORM_CENTER_X;
transformY = DIMENSIONS.TRANSFORM_CENTER_Y;
}
const constrainedPosition = this.applyConstraints({
x,
y,
position,
viewportWidth,
viewportHeight,
playerWidth
});
return {
...constrainedPosition,
transformX,
transformY
};
}
/**
* Applies viewport constraints to keep the player within bounds
* @param params Constraint parameters
* @returns Constrained coordinates
*/
static applyConstraints(params) {
const { x, y, position, viewportWidth, viewportHeight, playerWidth } = params;
let constrainedX = x;
let constrainedY = y;
if (position.includes(POSITION_KEYWORDS.BOTTOM) && !position.includes(POSITION_KEYWORDS.CENTER)) {
constrainedY = viewportHeight - DIMENSIONS.DEFAULT_MARGIN;
}
if (position.includes(POSITION_KEYWORDS.RIGHT) && !position.includes(POSITION_KEYWORDS.CENTER)) {
constrainedX = Math.max(
DIMENSIONS.DEFAULT_MARGIN,
Math.min(constrainedX, viewportWidth - playerWidth - RIGHT_EDGE_MARGIN)
);
}
constrainedX = Math.max(
DIMENSIONS.DEFAULT_MARGIN,
Math.min(constrainedX, viewportWidth - playerWidth - RIGHT_EDGE_MARGIN)
);
constrainedY = Math.max(
DIMENSIONS.DEFAULT_MARGIN,
Math.min(constrainedY, viewportHeight - DIMENSIONS.DEFAULT_MARGIN)
);
return { x: constrainedX, y: constrainedY };
}
/**
* Calculates position for drag operations with constraints
* @param params Drag calculation parameters
* @returns Constrained drag position
*/
static calculateDragPosition(params) {
const {
x,
y,
position,
viewportWidth = window.innerWidth,
viewportHeight = window.innerHeight,
playerWidth = getPlayerWidth()
} = params;
let constrainedX = x;
let constrainedY = y;
if ((position == null ? void 0 : position.includes(POSITION_KEYWORDS.RIGHT)) && !position.includes(POSITION_KEYWORDS.CENTER)) {
constrainedX = Math.min(constrainedX, viewportWidth - playerWidth - RIGHT_EDGE_MARGIN);
}
const minX = DIMENSIONS.DEFAULT_MARGIN;
const maxX = viewportWidth - playerWidth - RIGHT_EDGE_MARGIN;
constrainedX = Math.max(minX, Math.min(constrainedX, maxX));
constrainedY = Math.max(
DIMENSIONS.DEFAULT_MARGIN,
Math.min(constrainedY, viewportHeight - DIMENSIONS.DEFAULT_MARGIN)
);
return { x: constrainedX, y: constrainedY };
}
/**
* Determines transform values based on position string
* @param position Position string (e.g., 'bottom-right', 'center')
* @returns Transform values for CSS
*/
static getTransforms(position) {
let transformX = "0";
let transformY = "0";
if (position.includes(POSITION_KEYWORDS.CENTER)) {
transformX = DIMENSIONS.TRANSFORM_CENTER_X;
transformY = DIMENSIONS.TRANSFORM_CENTER_Y;
} else {
if (position.includes(POSITION_KEYWORDS.BOTTOM)) {
transformY = DIMENSIONS.TRANSFORM_BOTTOM;
}
}
return { transformX, transformY };
}
/**
* Checks if a position change should force repositioning (e.g., bottom-left should force left alignment)
* @param currentPosition Current position string
* @param currentY Current Y coordinate
* @param isDragging Whether the player is currently being dragged
* @returns Whether to force repositioning
*/
static shouldForceReposition(currentPosition, currentY, isDragging) {
if (currentPosition === "bottom-left" && !isDragging && (currentY === window.innerHeight - DIMENSIONS.DEFAULT_MARGIN || Math.abs(currentY - (window.innerHeight - DIMENSIONS.DEFAULT_MARGIN)) < 5)) {
return true;
}
return false;
}
/**
* Gets player dimensions for constraint calculations
* @returns Player dimension information
*/
static getPlayerDimensions() {
const width = getPlayerWidth();
const minX = DIMENSIONS.DEFAULT_MARGIN;
const maxX = window.innerWidth - width - RIGHT_EDGE_MARGIN;
return { width, minX, maxX };
}
}
const createInitialContext = () => ({
currentStep: null,
error: null
});
const saltfishStore = createStore()(
immer((set2, get) => {
const stateMachine = new PlayerStateMachine(
playerStateMachineConfig,
createInitialContext()
);
return {
// State
config: null,
user: null,
userData: null,
// User data from backend
currentState: stateMachine.getState(),
// Get initial state from machine
manifest: null,
currentStepId: null,
isMinimized: false,
position: null,
progress: {},
error: null,
stateMachine,
playlistOptions: null,
backendPlaylists: [],
// Renamed state for playlists from backend
// Actions
initialize: async (config) => {
set2((state) => {
state.config = config;
state.currentState = state.stateMachine.send({ type: "INITIALIZE" });
});
},
// Add setPlaylistOptions action
setPlaylistOptions: (options) => {
set2((state) => {
state.playlistOptions = options;
if (options.position) {
const calculatedPosition = PositionCalculator.calculatePosition({
position: options.position
});
state.position = { x: calculatedPosition.x, y: calculatedPosition.y };
}
});
},
identifyUser: (userId, userData) => {
const user = {
id: userId,
...userData
};
set2((state) => {
state.user = user;
});
},
setUserData: (userData) => {
set2((state) => {
state.userData = userData;
});
},
setManifest: (manifest, startStepId) => {
set2((state) => {
state.manifest = manifest;
state.currentStepId = startStepId;
state.currentState = state.stateMachine.send({ type: "LOAD_MANIFEST" });
});
},
play: () => {
const { currentState } = get();
set2((state) => {
state.currentState = state.stateMachine.send({ type: "PLAY" });
});
},
pause: () => {
const { currentState } = get();
set2((state) => {
state.currentState = state.stateMachine.send({ type: "PAUSE" });
});
},
minimize: () => {
const { currentState } = get();
set2((state) => {
if (state.currentState === "playing") {
state.currentState = state.stateMachine.send({ type: "PAUSE" });
}
state.currentState = state.stateMachine.send({ type: "MINIMIZE" });
state.isMinimized = true;
});
},
maximize: () => {
set2((state) => {
state.currentState = state.stateMachine.send({ type: "MAXIMIZE" });
state.isMinimized = false;
});
},
setPosition: (x, y) => {
set2((state) => {
state.position = { x, y };
});
},
goToStep: (stepId) => {
const { manifest } = get();
if (stepId === "completed") {
set2((state) => {
var _a;
state.currentState = state.stateMachine.send({ type: "COMPLETE_PLAYLIST" });
const playlistPersistence = ((_a = state.playlistOptions) == null ? void 0 : _a.persistence) ?? true;
if (manifest && playlistPersistence) {
delete state.progress[manifest.id];
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEYS.PROGRESS, JSON.stringify(state.progress));
}
log(`Store: Removed completed playlist ${manifest.id} from localStorage`);
}
});
return;
}
if (manifest && manifest.steps.some((step) => step.id === stepId)) {
const targetStep = manifest.steps.find((step) => step.id === stepId);
set2((state) => {
var _a, _b;
state.currentStepId = stepId;
if (targetStep) {
state.currentState = state.stateMachine.send({
type: "TRANSITION_TO_STEP",
step: targetStep
});
if (state.position) {
const positionToUse = targetStep.position || ((_a = state.playlistOptions) == null ? void 0 : _a.position) || "bottom-right";
const calculatedPosition = PositionCalculator.calculatePosition({
position: positionToUse
});
state.position = { x: calculatedPosition.x, y: calculatedPosition.y };
}
}
state.progress[manifest.id] = {
...state.progress[manifest.id],
lastStepId: stepId,
lastVisited: (/* @__PURE__ */ new Date()).toISOString()
};
const playlistPersistence = ((_b = state.playlistOptions) == null ? void 0 : _b.persistence) ?? true;
if (playlistPersistence && typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEYS.PROGRESS, JSON.stringify(state.progress));
}
});
}
},
reset: () => {
set2((state) => {
state.config = null;
state.user = null;
state.userData = null;
state.currentState = "idle";
state.manifest = null;
state.currentStepId = null;
state.isMinimized = false;
state.position = null;
state.progress = {};
state.error = null;
state.stateMachine = new PlayerStateMachine(
playerStateMachineConfig,
createInitialContext()
);
});
},
setError: (error2) => {
set2((state) => {
state.currentState = state.stateMachine.send({
type: "ERROR",
error: error2
});
state.error = error2;
});
},
setAutoplayFallback: () => {
set2((state) => {
state.currentState = state.stateMachine.send({ type: "AUTOPLAY_FALLBACK" });
});
},
// Correct action for playlists from backend
setBackendPlaylists: (playlists) => {
set2((state) => {
state.backendPlaylists = playlists;
});
},
completePlaylist: () => {
set2((state) => {
var _a;
const { manifest } = state;
state.currentState = state.stateMachine.send({ type: "COMPLETE_PLAYLIST" });
const playlistPersistence = ((_a = state.playlistOptions) == null ? void 0 : _a.persistence) ?? true;
if (manifest && playlistPersistence) {
delete state.progress[manifest.id];
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEYS.PROGRESS, JSON.stringify(state.progress));
}
log(`Store: Removed completed playlist ${manifest.id} from localStorage`);
}
});
},
// Add new method to reset playlist state while preserving config and user data
resetForNewPlaylist: () => {
set2((state) => {
const preservedConfig = state.config;
const preservedUser = state.user;
const preservedUserData = state.userData;
const preservedProgress = state.progress;
state.manifest = null;
state.currentStepId = null;
state.isMinimized = false;
state.position = null;
state.error = null;
state.playlistOptions = null;
state.stateMachine = new PlayerStateMachine(
playerStateMachineConfig,
createInitialContext()
);
state.currentState = state.stateMachine.getState();
state.config = preservedConfig;
state.user = preservedUser;
state.userData = preservedUserData;
state.progress = preservedProgress;
});
},
loadPlaylistProgress: (playlistId, progress) => {
set2((state) => {
state.progress[playlistId] = progress;
});
}
};
})
);
const useSaltfishStore = {
getState: () => saltfishStore.getState(),
setState: saltfishStore.setState,
subscribe: saltfishStore.subscribe,
destroy: saltfishStore.destroy
};
class DeviceDetector {
/**
* Detects if the current device is mobile using multiple methods
* @returns boolean indicating if device is mobile
*/
static isMobile() {
return this.getDeviceInfo().isMobile;
}
/**
* Detects if the current device is a tablet
* @returns boolean indicating if device is a tablet
*/
static isTablet() {
return this.getDeviceInfo().isTablet;
}
/**
* Detects if the current device is desktop
* @returns boolean indicating if device is desktop
*/
static isDesktop() {
return this.getDeviceInfo().isDesktop;
}
/**
* Detects if the current device supports touch
* @returns boolean indicating if device supports touch
*/
static isTouchDevice() {
return this.getDeviceInfo().isTouchDevice;
}
/**
* Gets the current screen orientation
* @returns 'portrait' or 'landscape'
*/
static getOrientation() {
return this.getDeviceInfo().orientation;
}
/**
* Gets comprehensive device information
* @returns DeviceInfo object with all device details
*/
static getDeviceInfo() {
if (this.cachedDeviceInfo) {
this.cachedDeviceInfo.orientation = this.detectOrientation();
return this.cachedDeviceInfo;
}
const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
const isTouchDevice2 = this.detectTouchSupport();
const { width, height } = this.getScreenDimensions();
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
const tabletRegex = /iPad|Android(?!.*Mobile)|Tablet|tablet/i;
const isMobileUserAgent = mobileRegex.test(userAgent) && !tabletRegex.test(userAgent);
const isTabletUserAgent = tabletRegex.test(userAgent);
const screenSize = this.getScreenSize(width, height);
const isMobileByScreen = screenSize === "small" && Math.min(width, height) < 768;
const isTabletByScreen = screenSize === "medium" && !isMobileByScreen;
const isMobile2 = isMobileUserAgent || isMobileByScreen && isTouchDevice2;
const isTablet2 = isTabletUserAgent || isTabletByScreen && isTouchDevice2 && !isMobile2;
const isDesktop2 = !isMobile2 && !isTablet2;
const deviceInfo = {
isMobile: isMobile2,
isTablet: isTablet2,
isDesktop: isDesktop2,
isTouchDevice: isTouchDevice2,
screenSize,
orientation: this.detectOrientation(),
userAgent
};
this.cachedDeviceInfo = deviceInfo;
return deviceInfo;
}
/**
* Detects touch support
* @returns boolean indicating if touch is supported
*/
static detectTouchSupport() {
if (typeof window === "undefined") return false;
return "ontouchstart" in window || navigator.maxTouchPoints > 0 || // @ts-ignore - some older browsers
navigator.msMaxTouchPoints > 0;
}
/**
* Gets screen dimensions
* @returns object with width and height
*/
static getScreenDimensions() {
if (typeof window === "undefined") {
return { width: 1920, height: 1080 };
}
return {
width: window.innerWidth || document.documentElement.clientWidth || 1920,
height: window.innerHeight || document.documentElement.clientHeight || 1080
};
}
/**
* Determines screen size category
* @param width Screen width
* @param height Screen height
* @returns Screen size category
*/
static getScreenSize(width, height) {
const minDimension = Math.min(width, height);
if (minDimension < 768) return "small";
if (minDimension < 1024) return "medium";
return "large";
}
/**
* Detects screen orientation
* @returns Current orientation
*/
static detectOrientation() {
if (typeof window === "undefined") return "landscape";
const { width, height } = this.getScreenDimensions();
return height > width ? "portrait" : "landscape";
}
/**
* Clears the cached device info (useful for testing or when device capabilities change)
*/
static clearCache() {
this.cachedDeviceInfo = null;
}
/**
* Sets up listeners for orientation and resize changes
* @param callback Function to call when device info changes
*/
static onDeviceChange(callback) {
if (typeof window === "undefined") {
return () => {
};
}
const handleChange = () => {
try {
this.clearCache();
callback(this.getDeviceInfo());
} catch (e) {
console.warn("DeviceDetector: Error in device change handler:", e);
}
};
window.addEventListener("orientationchange", handleChange);
window.addEventListener("resize", handleChange);
return () => {
window.removeEventListener("orientationchange", handleChange);
window.removeEventListener("resize", handleChange);
};
}
}
__publicField(DeviceDetector, "cachedDeviceInfo", null);
const isDeviceCompatible = (deviceType) => {
if (!deviceType || deviceType === "both") {
return true;
}
const deviceInfo = DeviceDetector.getDeviceInfo();
if (deviceType === "mobile") {
return deviceInfo.isMobile || deviceInfo.isTablet;
}
if (deviceType === "desktop") {
return deviceInfo.isDesktop;
}
return false;
};
const deviceDetection = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
__proto__: null,
DeviceDetector,
isDeviceCompatible
}, Symbol.toStringTag, { value: "Module" }));
class TranscriptManager {
constructor() {
__publicField(this, "transcriptContainer", null);
__publicField(this, "transcriptContent", null);
__publicField(this, "ccButton", null);
__publicField(this, "isVisible", false);
__publicField(this, "currentTranscript", null);
__publicField(this, "currentSegmentIndex", -1);
__publicField(this, "segments", []);
__publicField(this, "videoElement", null);
__publicField(this, "timeUpdateListener", null);
}
/**
* Initialize the transcript manager with a video element and CC button
*/
initialize(videoElement, ccButton) {
this.videoElement = videoElement;
this.ccButton = ccButton;
this.ccButton.addEventListener("click", this.handleCCButtonClick.bind(this));
this.timeUpdateListener = this.handleTimeUpdate.bind(this);
this.videoElement.addEventListener("timeupdate", this.timeUpdateListener);
}
/**
* Load transcript data for the current step
*/
loadTranscript(transcript) {
this.currentTranscript = transcript;
this.currentSegmentIndex = -1;
if (transcript) {
this.updateCCButtonState(true);
this.createTranscriptUI();
this.renderTranscriptSegments();
log(`TranscriptManager: Loaded transcript with ${transcript.segments.length} segments`);
} else {
this.updateCCButtonState(false);
this.hideTranscript();
}
}
/**
* Show or hide the transcript display
*/
toggleVisibility() {
if (!this.currentTranscript) return;
this.isVisible = !this.isVisible;
if (this.isVisible) {
this.showTranscript();
} else {
this.hideTranscript();
}
this.updateCCButtonState(!!this.currentTranscript);
log(`TranscriptManager: Transcript visibility toggled to ${this.isVisible}`);
}
/**
* Handle CC button click
*/
handleCCButtonClick(event) {
event.preventDefault();
event.stopPropagation();
this.toggleVisibility();
}
/**
* Handle video time updates for transcript synchronization
*/
handleTimeUpdate(_event) {
if (!this.currentTranscript || !this.isVisible || !this.videoElement) return;
const currentTime = this.videoElement.currentTime;
const activeSegmentIndex = this.findActiveSegmentIndex(currentTime);
if (activeSegmentIndex !== this.currentSegmentIndex) {
this.highlightSegment(activeSegmentIndex);
this.currentSegmentIndex = activeSegmentIndex;
}
}
/**
* Find the active segment index based on current video time
*/
findActiveSegmentIndex(currentTime) {
if (!this.currentTranscript) return -1;
for (let i = 0; i < this.currentTranscript.segments.length; i++) {
const segment = this.currentTranscript.segments[i];
if (currentTime >= segment.start && currentTime <= segment.end) {
return i;
}
}
return -1;
}
/**
* Highlight the active segment
*/
highlightSegment(segmentIndex) {
this.segments.forEach((segment) => {
segment.classList.remove("sf-transcript__segment--active");
});
if (segmentIndex >= 0 && segmentIndex < this.segments.length) {
const activeSegment = this.segments[segmentIndex];
activeSegment.classList.add("sf-transcript__segment--active");
activeSegment.scrollIntoView({
behavior: "smooth",
block: "nearest"
});
}
}
/**
* Create the transcript UI container
*/
createTranscriptUI() {
var _a;
if (this.transcriptContainer) return;
const videoContainer = (_a = this.videoElement) == null ? void 0 : _a.closest(".sf-video-container");
if (!videoContainer) {
return;
}
this.transcriptContainer = document.createElement("div");
this.transcriptContainer.className = "sf-transcript";
this.transcriptContainer.style.display = "none";
this.transcriptContent = document.createElement("div");
this.transcriptContent.className = "sf-transcript__content";
if (this.transcriptContainer && this.transcriptContent) {
this.transcriptContainer.appendChild(this.transcriptContent);
videoContainer.appendChild(this.transcriptContainer);
}
}
/**
* Render transcript segments
*/
renderTranscriptSegments() {
if (!this.transcriptContent || !this.currentTranscript) return;
this.transcriptContent.innerHTML = "";
this.segments = [];
this.currentTranscript.segments.forEach((segment) => {
const segmentElement = document.createElement("div");
segmentElement.className = "sf-transcript__segment";
segmentElement.textContent = segment.text;
segmentElement.addEventListener("click", () => {
if (this.videoElement) {
this.videoElement.currentTime = segment.start;
}
});
if (this.transcriptContent) {
this.transcriptContent.appendChild(segmentElement);
this.segments.push(segmentElement);
}
});
log(`TranscriptManager: Rendered ${this.segments.length} transcript segments`);
}
/**
* Show the transcript
*/
showTranscript() {
if (this.transcriptContainer) {
this.transcriptContainer.style.display = "block";
this.transcriptContainer.classList.add("sf-transcript--visible");
}
}
/**
* Hide the transcript
*/
hideTranscript() {
if (this.transcriptContainer) {
this.transcriptContainer.style.display = "none";
this.transcriptContainer.classList.remove("sf-transcript--visible");
}
}
/**
* Update CC button state
*/
updateCCButtonState(hasTranscript) {
if (!this.ccButton) return;
if (hasTranscript) {
this.ccButton.style.display = "block";
this.ccButton.classList.toggle("sf-video-container__cc-button--active", this.isVisible);
} else {
this.ccButton.style.display = "none";
}
}
/**
* Clean up resources
*/
destroy() {
if (this.timeUpdateListener && this.videoElement) {
this.videoElement.removeEventListener("timeupdate", this.timeUpdateListener);
}
if (this.transcriptContainer && this.transcriptContainer.parentNode) {
this.transcriptContainer.parentNode.removeChild(this.transcriptContainer);
}
this.transcriptContainer = null;
this.transcriptContent = null;
this.ccButton = null;
this.videoElement = null;
this.timeUpdateListener = null;
this.currentTranscript = null;
this.segments = [];
this.currentSegmentIndex = -1;
this.isVisible = false;
}
}
class DevicePlaybackHandler {
constructor(deviceInfo) {
__publicField(this, "deviceInfo");
this.deviceInfo = deviceInfo;
}
/**
* Handle device change updates
*/
updateDeviceInfo(newDeviceInfo) {
this.deviceInfo = newDeviceInfo;
}
}
class MobilePlaybackHandler extends DevicePlaybackHandler {
getVideoElementConfig() {
return {
playsInline: true,
muted: true,
// Start muted for autoplay compatibility
controls: false,
preload: "metadata",
additionalAttributes: {
"webkit-playsinline": "true",
"playsinline": "true",
"x-webkit-airplay": "allow"
},
styles: {
width: "100%",
height: "100%",
objectFit: "cover",
backgroundColor: "white"
}
};
}
getControlsConfig() {
return {
buttonMinSize: { width: "44px", height: "44px" },
// Touch-friendly size
useTouch: true,
progressUpdateInterval: 500
// Slower for mobile performance
};
}
getAutoplayConfig(hasUserInteracted) {
return {
shouldStartMuted: !hasUserInteracted,
// Mute until first interaction
enableFallbackLoop: true,
fallbackTimeout: 3e3,
// 3 second timeout for mobile
requiresUserInteraction: true
};
}
configureVideoElement(video) {
const config = this.getVideoElementConfig();
video.playsInline = config.playsInline;
video.muted = config.muted;
video.controls = config.controls;
video.preload = config.preload;
Object.entries(config.additionalAttributes).forEach(([key, value]) => {
try {
video.setAttribute(key, value);
} catch (e) {
console.warn(`MobilePlaybackHandler: Failed to set attribute ${key}:`, e);
}
});
Object.entries(config.styles).forEach(([key, value]) => {
try {
video.style[key] = value;
} catch (e) {
console.warn(`MobilePlaybackHandler: Failed to set style ${key}:`, e);
}
});
}
configureControlElement(element) {
const config = this.getControlsConfig();
element.style.minWidth = config.buttonMinSize.width;
element.style.minHeight = config.buttonMinSize.height;
}
async handlePlayAttempt(video, hasUserInteracted) {
video.muted = false;
video.loop = false;
try {
await video.play();
return true;
} catch (error2) {
await this.handleAutoplayFallback(video);
return false;
}
}
async handleAutoplayFallback(video) {
video.muted = true;
video.loop = true;
video.playsInline = true;
video.setAttribute("playsinline", "true");
video.setAttribute("webkit-playsinline", "true");
try {
await video.play();
} catch (fallbackError) {
console.error("MobilePlaybackHandler: All autoplay attempts failed");
}
}
/**
* Clean up resources when handler is destroyed
*/
destroy() {
}
getProgressUpdateFrequency(isAutoplayFallback) {
return isAutoplayFallback ? 500 : 16;
}
}
class DesktopPlaybackHandler extends DevicePlaybackHandler {
getVideoElementConfig() {
return {
playsInline: true,
muted: false,
// Desktop can start unmuted
controls: false,
preload: "metadata",
additionalAttributes: {},
styles: {
backgroundColor: "white"
}
};
}
getControlsConfig() {
return {
buttonMinSize: { width: "auto", height: "auto" },
// Standard desktop size
useTouch: false,
progressUpdateInterval: 16
// 60fps for smooth desktop experience
};
}
getAutoplayConfig(_hasUserInteracted) {
return {
shouldStartMuted: false,
// Desktop usually allows unmuted autoplay
enableFallbackLoop: false,
fallbackTimeout: 5e3,
// 5 second timeout for desktop
requiresUserInteraction: false
};
}
configureVideoElement(video) {
const config = this.getVideoElementConfig();
video.playsInline = config.playsInline;
video.muted = config.muted;
video.controls = config.controls;
video.preload = config.preload;
Object.entries(config.styles).forEach(([key, value]) => {
video.style[key] = value;
});
}
configureControlElement(_element) {
}
async handlePlayAttempt(video, _hasUserInteracted) {
video.muted = false;
video.loop = false;
try {
await video.play();
return true;
} catch (error2) {
await this.handleAutoplayFallback(video);
return false;
}
}
async handleAutoplayFallback(video) {
video.muted = true;
video.loop = true;
try {
await video.play();
} catch (fallbackError) {
console.error("DesktopPlaybackHandler: All autoplay attempts failed");
throw new Error("Desktop autoplay completely blocked");
}
}
getProgressUpdateFrequency(_isAutoplayFallback) {
return 16;
}
/**
* Clean up resources when handler is destroyed
*/
destroy() {
}
}
function createDevicePlaybackHandler(deviceInfo) {
if (deviceInfo.isMobile) {
return new MobilePlaybackHandler(deviceInfo);
} else {
return new DesktopPlaybackHandler(deviceInfo);
}
}
class VideoManager {
constructor() {
// Dual video elements for seamless transitions
__publicField(this, "currentVideo", null);
__publicField(this, "nextVideo", null);
__publicField(this, "activeVideoIndex", 0);
// Tracks which video is currently active
__publicField(this, "container", null);
__publicField(this, "progressBar", null);
__publicField(this, "muteButton", null);
__publicField(this, "ccButton", null);
__publicField(this, "transcriptManager");
__publicField(this, "preloadedVideos", /* @__PURE__ */ new Map());
__publicField(this, "updateInterval", null);
__publicField(this, "animationFrameId", null);
__publicField(this, "lastTimeupdateEvent", 0);
// Track the current video URL and position
__publicField(this, "currentVideoUrl", "");
__publicField(this, "nextVideoUrl", "");
__publicField(this, "playbackPositions", /* @__PURE__ */ new Map());
// Controls how video handles completion
__publicField(this, "completionPolicy", "auto");
__publicField(this, "videoEndedCallback", null);
// Device-specific playback handling
__publicField(this, "deviceHandler");
__publicField(this, "deviceChangeCleanup", null);
__publicField(this, "hasUserInteracted", false);
// Autoplay fallback timeout
__publicField(this, "autoplayFallbackTimeout", null);
/**
* Handles video ended event
*/
__publicField(this, "handleVideoEnded", (event) => {
const video = event.target;
const activeVideo = this.getActiveVideo();
if (video !== activeVideo) {
return;
}
const store = useSaltfishStore.getState();
if (store.currentState === "autoplayBlocked") {
return;
}
if (this.progressBar) {
this.progressBar.style.transition = "width 0.2s ease-out";
this.progressBar.style.width = "100%";
}
if (this.completionPolicy === "auto") {
this.handleAutoVideoEnded();
} else {
this.handleManualVideoEnded();
}
});
/**
* Handles video time update (for debugging)
*/
__publicField(this, "handleTimeUpdate", (event) => {
const video = event.target;
const activeVideo = this.getActiveVideo();
if (video === activeVideo && Math.floor(video.currentTime) % 10 === 0) ;
});
/**
* Handles video error event
*/
__publicField(this, "handleVideoError", (event) => {
const video = event.target;
console.error("VideoManager: Video error", video.error);
});
/**
* Handles detailed time updates for smooth progress bar
*/
__publicField(this, "handleDetailedTimeUpdate", () => {
var _a;
this.lastTimeupdateEvent = Date.now();
if (this.progressBar && ((_a = this.getActiveVideo()) == null ? void 0 : _a.paused) === false) {
this.progressBar.style.transition = "width 0.1s linear";
}
});
/**
* Handles video ended event for automatic completion policy
*/
__publicField(this, "handleAutoVideoEnded", () => {
const store = useSaltfishStore.getState();
const currentStepId = store.currentStepId;
if (!currentStepId || !store.manifest) {
return;
}
const currentStep = store.manifest.steps.find((step) => step.id === currentStepId);
if (!currentStep) {
return;
}
if (this.videoEndedCallback) {
this.videoEndedCallback();
}
const hasUrlPathTransitions = currentStep.transitions.some((t) => t.type === "url-path");
const hasDomClickTransitions = currentStep.transitions.some((t) => t.type === "dom-click");
const hasDomElementVisibleTransitions = currentStep.transitions.some((t) => t.type === "dom-element-visible");
const hasButtons = currentStep.buttons && currentStep.buttons.length > 0;
if (hasUrlPathTransitions || hasDomClickTransitions || hasDomElementVisibleTransitions || hasButtons) {
return;
}
if (currentStep.transitions.length > 0) {
const defaultTransition = currentStep.transitions[0];
const nextStepId = defaultTransition.nextStep;
store.goToStep(nextStepId);
} else {
if (store.completePlaylist) {
store.completePlaylist();
} else {
store.goToStep("completed");
}
}
});
/**
* Handles video ended event for manual completion policy
*/
__publicField(this, "handleManualVideoEnded", () => {
if (this.videoEndedCallback) {
this.videoEndedCallback();
}
});
/**
* Handles click on the progress bar to seek
*/
__publicField(this, "handleProgressBarClick", (event) => {
const controls = event.currentTarget;
const activeVideo = this.getActiveVideo();
if (!activeVideo || activeVideo.duration <= 0) return;
const rect = controls.getBoundingClientRect();
const clickPosition = (event.clientX - rect.left) / rect.width;
const seekTime = activeVideo.duration * clickPosition;
if (this.progressBar) {
this.progressBar.style.transition = "none";
this.progressBar.style.width = `${clickPosition * 100}%`;
}
this.seek(seekTime);
event.preventDefault();
event.stopPropagation();
});
/**
* Handles seeking events to ensure smooth progress updates
*/
__publicField(this, "handleSeeking", () => {
if (this.progressBar) {
this.progressBar.style.transition = "none";
this.updateProgress();
}
});
/**
* Handles seeked events (seeking ended)
*/
__publicField(this, "handleSeeked", () => {
if (this.progressBar) {
const activeVideo = this.getActiveVideo();
if (activeVideo && !activeVideo.paused) {
this.updateProgress();
void this.progressBar.offsetWidth;
this.progressBar.style.transition = "width 0.1s linear";
}
}
});
const deviceInfo = DeviceDetector.getDeviceInfo();
this.deviceHandler = createDevicePlaybackHandler(deviceInfo);
this.transcriptManager = new TranscriptManager();
this.deviceChangeCleanup = DeviceDetector.onDeviceChange((newDeviceInfo) => {
this.deviceHandler.updateDeviceInfo(newDeviceInfo);
this.handleDeviceChange(newDeviceInfo);
});
}
/**
* Gets current device information
* @returns DeviceInfo object with device details
*/
getDeviceInfo() {
return DeviceDetector.getDeviceInfo();
}
/**
* Checks if the current device is mobile
* @returns boolean indicating if device is mobile
*/
isMobileDevice() {
return this.getDeviceInfo().isMobile;
}
/**
* Handles device information changes (orientation, resize, etc.)
* @param deviceInfo Updated device information
*/
handleDeviceChange(deviceInfo) {
}
/**
* Creates video player elements
* @param container - The container element for the video player
*/
create(container) {
this.container = document.createElement("div");
this.container.className = "sf-video-container";
container.appendChild(this.container);
this.currentVideo = document.createElement("video");
this.currentVideo.className = "sf-video-container__video sf-video-container__video--current";
this.deviceHandler.configureVideoElement(this.currentVideo);
this.container.appendChild(this.currentVideo);
this.nextVideo = document.createElement("video");
this.nextVideo.className = "sf-video-container__video sf-video-container__video--next";
this.deviceHandler.configureVideoElement(this.nextVideo);
this.nextVideo.style.backgroundColor = "black";
this.nextVideo.style.display = "none";
this.container.appendChild(this.nextVideo);
const controls = document.createElement("div");
controls.className = "sf-video-container__controls";
this.container.appendChild(controls);
this.progressBar = document.createElement("div");
this.progressBar.className = "sf-video-container__progress";
controls.appendChild(this.progressBar);
const controlsConfig = this.deviceHandler.getControlsConfig();
if (controlsConfig.useTouch) {
controls.addEventListener("touchend", this.handleProgressBarClick);
}
controls.addEventListener("click", this.handleProgressBarClick);
this.muteButton = document.createElement("button");
this.muteButton.className = "sf-video-container__mute-button";
this.deviceHandler.configureControlElement(this.muteButton);
this.muteButton.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="none"></polygon>
<path d="M15 9c0.8 0.8 1.5 1.9 1.5 3s-0.7 2.2-1.5 3" stroke="currentColor"></path>
<path d="M19 7c1.6 1.6 2.5 3.8 2.5 6s-0.9 4.4-2.5 6" stroke="currentColor"></path>
</svg>
`;
if (controlsConfig.useTouch) {
this.muteButton.addEventListener("touchend", (event) => {
event.preventDefault();
this.toggleMute(event);
});
}
this.muteButton.addEventListener("click", (event) => {
this.toggleMute(event);
});
this.container.appendChild(this.muteButton);
this.updateMuteButtonIcon();
this.ccButton = document.createElement("button");
this.ccButton.className = "sf-video-container__cc-button";
this.deviceHandler.configureControlElement(this.ccButton);
this.ccButton.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="20" height="12" rx="2" ry="2"></rect>
<path d="M7 10h1v4H7z"></path>
<path d="M11 10h1v4h-1z"></path>
<path d="M15 10h1v4h-1z"></path>
</svg>
`;
if (controlsConfig.useTouch) {
this.ccButton.addEventListener("touchend", (event) => {
event.preventDefault();
});
}
this.ccButton.addEventListener("click", (event) => {
event.preventDefault();
});
this.container.appendChild(this.ccButton);
this.addEventListeners();
if (this.currentVideo && this.ccButton) {
this.transcriptManager.initialize(this.currentVideo, this.ccButton);
}
}
/**
* Returns the currently active video element
*/
getActiveVideo() {
return this.activeVideoIndex === 0 ? this.currentVideo : this.nextVideo;
}
/**
* Returns the inactive video element (for preloading)
*/
getInactiveVideo() {
return this.activeVideoIndex === 0 ? this.nextVideo : this.currentVideo;
}
/**
* Swaps between the two video elements
*/
swapVideos() {
const activeVideo = this.getActiveVideo();
const inactiveVideo = this.getInactiveVideo();
if (!activeVideo || !inactiveVideo) return;
activeVideo.pause();
activeVideo.style.display = "none";
inactiveVideo.style.display = "block";
this.activeVideoIndex = this.activeVideoIndex === 0 ? 1 : 0;
this.currentVideoUrl = this.nextVideoUrl;
this.nextVideoUrl = "";
}
/**
* Loads a video from URL
* @param url - URL of the video to load
*/
async loadVideo(url) {
var _a;
const activeVideo = this.getActiveVideo();
if (!activeVideo) return;
try {
if (this.progressBar) {
this.progressBar.style.transition = "none";
this.progressBar.style.width = "0%";
setTimeout(() => {
if (this.progressBar) {
this.progressBar.style.transition = "width 0.1s linear";
}
}, 50);
}
const store = useSaltfishStore.getState();
const isPersistenceEnabled = ((_a = store.playlistOptions) == null ? void 0 : _a.persistence) ?? true;
if (this.currentVideoUrl === url && activeVideo.src && (activeVideo.src === url || activeVideo.src.endsWith(url))) {
if (isPersistenceEnabled) {
const savedPosition = this.playbackPositions.get(url);
if (savedPosition !== void 0 && savedPosition > 0 && Math.abs(activeVideo.currentTime - savedPosition) > 0.5) {
activeVideo.currentTime = savedPosition;
}
}
return;
}
if (isPersistenceEnabled && this.currentVideoUrl && activeVideo.currentTime > 0) {
this.playbackPositions.set(this.currentVideoUrl, activeVideo.currentTime);
}
const inactiveVideo = this.getInactiveVideo();
if (inactiveVideo && this.nextVideoUrl === url) {
this.swapVideos();
await new Promise((resolve) => {
const activeVideo2 = this.getActiveVideo();
if (!activeVideo2) {
return resolve();
}
if (activeVideo2.readyState >= 3) {
resolve();
} else {
const onCanPlay = () => {
activeVideo2.removeEventListener("canplay", onCanPlay);
resolve();
};
activeVideo2.addEventListener("canplay", onCanPlay);
}
});
return;
}
this.currentVideoUrl = url;
const preloadedVideo = this.preloadedVideos.get(url);
if (preloadedVideo) {
const objectUrl = URL.createObjectURL(preloadedVideo);
activeVideo.src = objectUrl;
this.preloadedVideos.delete(url);
} else {
activeVideo.src = url;
}
activeVideo.load();
await new Promise((resolve, reject) => {
if (!activeVideo) {
return resolve(void 0);
}
let loadTimeout;
const onLoadedData = () => {
if (!activeVideo) return;
clearTimeout(loadTimeout);
if (isPersistenceEnabled) {
const savedPosition = this.playbackPositions.get(url);
if (savedPosition !== void 0 && savedPosition > 0) {
const safePosition = Math.min(savedPosition, activeVideo.duration - 0.5);
activeVideo.currentTime = safePosition;
}
} else {
activeVideo.currentTime = 0;
}
activeVideo.removeEventListener("loadeddata", onLoadedData);
activeVideo.removeEventListener("error", onError);
resolve(void 0);
};
const onError = (error2) => {
var _a2, _b, _c;
clearTimeout(loadTimeout);
console.error("VideoManager: Video load error:", (_a2 = error2.target) == null ? void 0 : _a2.error);
activeVideo.removeEventListener("loadeddata", onLoadedData);
activeVideo.removeEventListener("error", onError);
reject(new Error(`Video load failed: ${((_c = (_b = error2.target) == null ? void 0 : _b.error) == null ? void 0 : _c.message) || "Unknown error"}`));
};
loadTimeout = window.setTimeout(() => {
console.error("VideoManager: Video load timeout after 10 seconds");
activeVideo.removeEventListener("loadeddata", onLoadedData);
activeVideo.removeEventListener("error", onError);
reject(new Error("Video load timeout"));
}, 1e4);
activeVideo.addEventListener("loadeddata", onLoadedData);
activeVideo.addEventListener("error", onError);
});
} catch (error2) {
console.error("VideoManager: Failed to load video:", error2);
throw new Error("Failed to load video");
}
}
/**
* Preloads a video for future playback to reduce transition delays
* @param url - URL of the video to preload
*/
preloadNextVideo(url) {
var _a;
if (!url || this.preloadedVideos.has(url)) {
return;
}
if (this.currentVideoUrl === url) {
return;
}
if (this.nextVideoUrl === url) {
return;
}
const inactiveVideo = this.getInactiveVideo();
if (inactiveVideo) {
this.nextVideoUrl = url;
inactiveVideo.src = url;
inactiveVideo.load();
inactiveVideo.preload = "auto";
inactiveVideo.muted = ((_a = this.getActiveVideo()) == null ? void 0 : _a.muted) ?? true;
} else {
fetch(url).then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch video: ${response.statusText}`);
}
return response.blob();
}).then((blob) => {
this.preloadedVideos.set(url, blob);
}).catch((error2) => {
console.error(`VideoManager: Error preloading video ${url}:`, error2);
});
}
}
/**
* Plays the video
*/
play() {
var _a;
const activeVideo = this.getActiveVideo();
if (!activeVideo) {
console.error("VideoManager: No active video element found");
return;
}
if (!activeVideo.paused) {
return;
}
if (activeVideo.ended) {
activeVideo.currentTime = 0;
}
const store = useSaltfishStore.getState();
const isPersistenceEnabled = ((_a = store.playlistOptions) == null ? void 0 : _a.persistence) ?? true;
if (isPersistenceEnabled && this.currentVideoUrl) {
const savedPosition = this.playbackPositions.get(this.currentVideoUrl);
if (savedPosition && Math.abs(activeVideo.currentTime - savedPosition) > 0.5) {
activeVideo.currentTime = savedPosition;
}
}
const comingFromAutoplayFallback = store.currentState === "autoplayBlocked";
if (comingFromAutoplayFallback) {
activeVideo.currentTime = 0;
activeVideo.loop = false;
this.setMuted(false);
this.showProgressBar();
this.showMuteButton();
this.startProgressUpdates();
}
if (!comingFromAutoplayFallback) {
this.showProgressBar();
}
this.deviceHandler.handlePlayAttempt(activeVideo, this.hasUserInteracted).then((playSucceeded) => {
if (!activeVideo) return;
if (playSucceeded) {
if (!comingFromAutoplayFallback) {
this.startProgressUpdates();
}
} else {
store.setAutoplayFallback();
if (this.isMobileDevice()) {
activeVideo.playsInline = true;
activeVideo.setAttribute("playsinline", "true");
activeVideo.setAttribute("webkit-playsinline", "true");
setTimeout(() => {
if (activeVideo.paused) {
activeVideo.play().catch(() => {
});
}
}, 200);
}
}
}).catch(() => {
console.warn("VideoManager: Autoplay handler threw error - browser has strict autoplay policy");
const store2 = useSaltfishStore.getState();
store2.setAutoplayFallback();
});
}
/**
* Pauses the video
*/
pause() {
const activeVideo = this.getActiveVideo();
if (!activeVideo) return;
if (activeVideo.paused) {
return;
}
this.updateProgress();
if (this.progressBar) {
this.progressBar.style.transition = "none";
}
activeVideo.pause();
this.stopProgressUpdates();
}
/**
* Seeks to a specified time in the video
* @param time - The time to seek to in seconds
*/
seek(time) {
const activeVideo = this.getActiveVideo();
if (!activeVideo) return;
if (this.progressBar) {
this.progressBar.style.transition = "none";
const percent = time / activeVideo.duration * 100;
this.progressBar.style.width = `${percent}%`;
}
activeVideo.currentTime = time;
this.updateProgress();
}
/**
* Gets the current playback time of the video
* @returns The current time in seconds
*/
getCurrentTime() {
const activeVideo = this.getActiveVideo();
return activeVideo ? activeVideo.currentTime : 0;
}
/**
* Gets the duration of the video
* @returns The duration in seconds
*/
getDuration() {
const activeVideo = this.getActiveVideo();
return activeVideo ? activeVideo.duration : 0;
}
/**
* Gets the active video element
* @returns The video element
*/
getVideoElement() {
return this.getActiveVideo();
}
/**
* Destroys the video player and cleans up resources
*/
destroy() {
this.stopProgressUpdates();
this.removeEventListeners();
this.transcriptManager.destroy();
if (this.autoplayFallbackTimeout !== null) {
window.clearTimeout(this.autoplayFallbackTimeout);
this.autoplayFallbackTimeout = null;
}
if (this.deviceChangeCleanup) {
this.deviceChangeCleanup();
this.deviceChangeCleanup = null;
}
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.currentVideo = null;
this.nextVideo = null;
this.container = null;
this.progressBar = null;
this.muteButton = null;
this.ccButton = null;
this.currentVideoUrl = "";
this.nextVideoUrl = "";
this.videoEndedCallback = null;
}
/**
* Adds event listeners to the video elements
*/
addEventListeners() {
const currentVideo = this.currentVideo;
const nextVideo = this.nextVideo;
if (currentVideo) {
currentVideo.addEventListener("ended", this.handleVideoEnded);
currentVideo.addEventListener("timeupdate", this.handleDetailedTimeUpdate);
currentVideo.addEventListener("error", this.handleVideoError);
currentVideo.addEventListener("seeking", this.handleSeeking);
currentVideo.addEventListener("seeked", this.handleSeeked);
}
if (nextVideo) {
nextVideo.addEventListener("ended", this.handleVideoEnded);
nextVideo.addEventListener("timeupdate", this.handleDetailedTimeUpdate);
nextVideo.addEventListener("error", this.handleVideoError);
nextVideo.addEventListener("seeking", this.handleSeeking);
nextVideo.addEventListener("seeked", this.handleSeeked);
}
if (this.container) {
const controls = this.container.querySelector(".sf-video-container__controls");
if (controls) {
controls.addEventListener("click", this.handleProgressBarClick);
}
}
}
/**
* Removes event listeners from the video elements
*/
removeEventListeners() {
const currentVideo = this.currentVideo;
const nextVideo = this.nextVideo;
if (currentVideo) {
currentVideo.removeEventListener("ended", this.handleVideoEnded);
currentVideo.removeEventListener("timeupdate", this.handleDetailedTimeUpdate);
currentVideo.removeEventListener("error", this.handleVideoError);
currentVideo.removeEventListener("seeking", this.handleSeeking);
currentVideo.removeEventListener("seeked", this.handleSeeked);
}
if (nextVideo) {
nextVideo.removeEventListener("ended", this.handleVideoEnded);
nextVideo.removeEventListener("timeupdate", this.handleDetailedTimeUpdate);
nextVideo.removeEventListener("error", this.handleVideoError);
nextVideo.removeEventListener("seeking", this.handleSeeking);
nextVideo.removeEventListener("seeked", this.handleSeeked);
}
if (this.container) {
const controls = this.container.querySelector(".sf-video-container__controls");
if (controls) {
controls.removeEventListener("click", this.handleProgressBarClick);
}
}
}
/**
* Starts updating the progress bar
*/
startProgressUpdates() {
this.stopProgressUpdates();
const activeVideo = this.getActiveVideo();
if (!activeVideo) return;
const isAutoplayFallback = activeVideo.loop && activeVideo.muted;
const updateFrequency = this.deviceHandler.getProgressUpdateFrequency(isAutoplayFallback);
if (updateFrequency >= 16) {
const updateFrame = () => {
if (activeVideo && !activeVideo.paused && !activeVideo.ended) {
this.updateProgress();
}
this.animationFrameId = requestAnimationFrame(updateFrame);
};
this.animationFrameId = requestAnimationFrame(updateFrame);
} else {
const updateInterval = () => {
if (this.updateInterval !== null && activeVideo && !activeVideo.paused && !activeVideo.ended) {
this.updateProgress();
this.updateInterval = window.setTimeout(updateInterval, updateFrequency);
}
};
this.updateInterval = window.setTimeout(updateInterval, updateFrequency);
}
}
/**
* Stops updating the progress bar
*/
stopProgressUpdates() {
if (this.updateInterval !== null) {
window.clearInterval(this.updateInterval);
this.updateInterval = null;
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
/**
* Updates the progress bar based on current playback position
*/
updateProgress() {
if (!this.progressBar) return;
const activeVideo = this.getActiveVideo();
if (!activeVideo) return;
const currentTime = activeVideo.currentTime || 0;
const duration = activeVideo.duration || 0;
if (duration > 0) {
const percent = currentTime / duration * 100;
const wasTransitioning = this.progressBar.style.transition !== "none";
const timeSinceLastUpdate = Date.now() - this.lastTimeupdateEvent;
this.progressBar.style.transition = "none";
this.progressBar.style.width = `${percent}%`;
void this.progressBar.offsetWidth;
if (activeVideo.ended) {
this.progressBar.style.transition = "width 0.2s ease-out";
} else if (!activeVideo.paused && !activeVideo.seeking && wasTransitioning && timeSinceLastUpdate < 500) {
this.progressBar.style.transition = "width 0.1s linear";
}
}
}
/**
* Sets the muted state of both video elements
* @param muted - Whether to mute the video
*/
setMuted(muted) {
if (this.currentVideo) {
this.currentVideo.muted = muted;
}
if (this.nextVideo) {
this.nextVideo.muted = muted;
}
this.updateMuteButtonIcon();
}
/**
* Toggles the muted state of the video
* @param event - Optional mouse event
*/
toggleMute(event) {
const activeVideo = this.getActiveVideo();
if (!activeVideo) return;
const newMutedState = !activeVideo.muted;
this.setMuted(newMutedState);
if (event) {
event.preventDefault();
event.stopPropagation();
}
}
/**
* Updates the mute button icon based on muted state
*/
updateMuteButtonIcon() {
if (!this.muteButton) return;
const activeVideo = this.getActiveVideo();
const isMuted = activeVideo ? activeVideo.muted : true;
if (isMuted) {
this.muteButton.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="none"></polygon>
<line x1="23" y1="9" x2="17" y2="15"></line>
<line x1="17" y1="9" x2="23" y2="15"></line>
</svg>
`;
} else {
this.muteButton.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="none"></polygon>
<path d="M15 9c0.8 0.8 1.5 1.9 1.5 3s-0.7 2.2-1.5 3" stroke="currentColor"></path>
<path d="M19 7c1.6 1.6 2.5 3.8 2.5 6s-0.9 4.4-2.5 6" stroke="currentColor"></path>
</svg>
`;
}
}
/**
* Checks if the video is currently muted
* @returns Whether the video is muted
*/
isMuted() {
const activeVideo = this.getActiveVideo();
return activeVideo ? activeVideo.muted : true;
}
/**
* Sets the completion policy for the video
* @param policy - The completion policy to use
* @param callback - Optional callback to execute when video ends
*/
setCompletionPolicy(policy, callback) {
this.completionPolicy = policy;
this.videoEndedCallback = callback || null;
this.updateVideoEndedHandler();
}
/**
* Updates the video ended handler based on completion policy
*/
updateVideoEndedHandler() {
}
/**
* Hides the progress bar (used during autoplay fallback)
*/
hideProgressBar() {
if (this.container) {
const controls = this.container.querySelector(".sf-video-container__controls");
if (controls) {
controls.style.display = "none";
}
}
}
/**
* Shows the progress bar and resets it to proper state
*/
showProgressBar() {
if (this.container) {
const controls = this.container.querySelector(".sf-video-container__controls");
if (controls) {
const playerElement = controls.closest(".sf-player");
const isMinimized = playerElement == null ? void 0 : playerElement.classList.contains("sf-player--minimized");
if (!isMinimized) {
controls.style.display = "block";
} else {
controls.style.display = "";
}
}
}
if (this.progressBar) {
this.progressBar.style.transition = "none";
this.progressBar.style.width = "0%";
void this.progressBar.offsetWidth;
this.progressBar.style.transition = "width 0.1s linear";
}
const activeVideo = this.getActiveVideo();
if (activeVideo && !activeVideo.paused && !activeVideo.ended) {
this.startProgressUpdates();
}
}
/**
* Hides the mute button (used during autoplay fallback)
*/
hideMuteButton() {
if (this.muteButton) {
this.muteButton.style.display = "none";
}
}
/**
* Shows the mute button
*/
showMuteButton() {
if (this.muteButton) {
this.muteButton.style.display = "block";
}
}
/**
* Marks that the user has interacted with the video player
* This is important for mobile autoplay policies
*/
markUserInteraction() {
if (!this.hasUserInteracted) {
this.hasUserInteracted = true;
if (this.autoplayFallbackTimeout !== null) {
window.clearTimeout(this.autoplayFallbackTimeout);
this.autoplayFallbackTimeout = null;
}
}
}
/**
* Checks if user has interacted with the player
* @returns boolean indicating if user has interacted
*/
hasUserInteractedWith() {
return this.hasUserInteracted;
}
/**
* Resets user interaction state (called when starting a new playlist)
*/
resetUserInteraction() {
this.hasUserInteracted = false;
if (this.autoplayFallbackTimeout !== null) {
window.clearTimeout(this.autoplayFallbackTimeout);
this.autoplayFallbackTimeout = null;
}
}
/**
* Handles autoplay fallback click specifically for mobile Chrome
* @param videoElement - The video element to configure
*/
handleAutoplayFallbackClick(videoElement) {
this.markUserInteraction();
videoElement.muted = false;
videoElement.loop = false;
videoElement.currentTime = 0;
}
/**
* Load transcript data for the current video
* @param transcript - Transcript data to load
*/
loadTranscript(transcript) {
this.transcriptManager.loadTranscript(transcript);
}
}
class CursorManager {
constructor() {
__publicField(this, "cursor", null);
__publicField(this, "animationFrameId", null);
__publicField(this, "animationStartTime", null);
__publicField(this, "currentAnimation", null);
__publicField(this, "flashlightOverlay", null);
__publicField(this, "startX", null);
__publicField(this, "startY", null);
__publicField(this, "targetX", null);
__publicField(this, "targetY", null);
// Track if the cursor should be shown based on step configuration
__publicField(this, "shouldShowCursor", false);
// Store the last cursor position to avoid moving in from the side every time
__publicField(this, "lastCursorX", 50);
__publicField(this, "lastCursorY", 50);
// Track if this is the first animation (to determine if we should move in from the side)
__publicField(this, "isFirstAnimation", true);
// Track the current target element to maintain position when scrolling
__publicField(this, "currentTargetElement", null);
// Throttle scroll events for better performance
__publicField(this, "scrollThrottleTimeout", null);
// Store reference to bound event handler for proper cleanup
__publicField(this, "boundScrollHandler", null);
// Add properties to track scrollable parent containers
__publicField(this, "scrollableParents", []);
__publicField(this, "parentScrollHandlers", /* @__PURE__ */ new Map());
// Selection mode related properties
__publicField(this, "selectionElement", null);
__publicField(this, "isSelectionMode", false);
__publicField(this, "selectionStartWidth", null);
__publicField(this, "selectionStartHeight", null);
__publicField(this, "selectionTargetWidth", null);
__publicField(this, "selectionTargetHeight", null);
__publicField(this, "selectionPadding", 4);
// Default padding in pixels
// Selection drag properties
__publicField(this, "dragStartX", null);
__publicField(this, "dragStartY", null);
__publicField(this, "dragEndX", null);
__publicField(this, "dragEndY", null);
__publicField(this, "dragPhase", "move-to-start");
__publicField(this, "dragAnimationStartTime", null);
// Simplified movement parameters
__publicField(this, "cursorSpeed", 0.4);
// Base speed in pixels per millisecond (400px/s)
__publicField(this, "totalDistance", 0);
// Total distance to travel
__publicField(this, "controlPointX", null);
// Control point for curved path
__publicField(this, "controlPointY", null);
// Control point for curved path
// Add a private property for the mutation observer
__publicField(this, "targetMutationObserver", null);
}
/**
* Finds all scrollable parent containers of an element
* @param element - The element to find scrollable parents for
* @returns Array of scrollable parent elements
*/
findScrollableParents(element) {
const scrollableParents = [];
let parent = element.parentElement;
while (parent && parent !== document.body) {
const computedStyle = window.getComputedStyle(parent);
const overflow = computedStyle.overflow;
const overflowX = computedStyle.overflowX;
const overflowY = computedStyle.overflowY;
if (overflow === "scroll" || overflow === "auto" || overflowX === "scroll" || overflowX === "auto" || overflowY === "scroll" || overflowY === "auto") {
if (parent.scrollHeight > parent.clientHeight || parent.scrollWidth > parent.clientWidth) {
scrollableParents.push(parent);
}
}
parent = parent.parentElement;
}
return scrollableParents;
}
/**
* Adds scroll event listeners to scrollable parent containers
* @param element - The target element whose parents should be monitored
*/
addScrollListenersToParents(element) {
this.removeScrollListenersFromParents();
this.scrollableParents = this.findScrollableParents(element);
this.scrollableParents.forEach((parent) => {
const handler = this.handleScroll.bind(this);
this.parentScrollHandlers.set(parent, handler);
parent.addEventListener("scroll", handler, { passive: true });
});
}
/**
* Removes scroll event listeners from all tracked parent containers
*/
removeScrollListenersFromParents() {
this.parentScrollHandlers.forEach((handler, parent) => {
parent.removeEventListener("scroll", handler);
});
this.parentScrollHandlers.clear();
this.scrollableParents = [];
}
/**
* Helper function to find an element in the document
* @param selector - CSS selector
* @returns - The found element or null
*/
findElement(selector) {
const element = document.querySelector(selector);
if (element) {
return element;
}
return null;
}
/**
* Checks if an element is completely visible in the viewport and within all scrollable parent containers
* @param element - The element to check
* @returns - Whether the entire element is visible in viewport and all scrollable parents
*/
isElementInViewport(element) {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
const isCompletelyInWindow = rect.top >= 0 && // Top edge is visible
rect.bottom <= windowHeight && // Bottom edge is visible
rect.left >= 0 && // Left edge is visible
rect.right <= windowWidth;
if (!isCompletelyInWindow) {
return false;
}
const scrollableParents = this.findScrollableParents(element);
for (const parent of scrollableParents) {
const parentRect = parent.getBoundingClientRect();
const isCompletelyInParent = rect.top >= parentRect.top && // Top edge is within parent
rect.bottom <= parentRect.bottom && // Bottom edge is within parent
rect.left >= parentRect.left && // Left edge is within parent
rect.right <= parentRect.right;
if (!isCompletelyInParent) {
return false;
}
}
return true;
}
/**
* Scrolls an element into view smoothly, handling both window and parent container scrolling
* @param element - The element to scroll into view
* @returns - Promise that resolves when scrolling is complete
*/
async scrollElementIntoView(element) {
return new Promise((resolve) => {
const scrollableParents = this.findScrollableParents(element);
if (scrollableParents.length === 0) {
element.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center"
});
let scrollEndTimer = null;
const onScroll = () => {
if (scrollEndTimer !== null) {
clearTimeout(scrollEndTimer);
}
scrollEndTimer = window.setTimeout(() => {
window.removeEventListener("scroll", onScroll);
resolve();
}, 100);
};
window.addEventListener("scroll", onScroll);
setTimeout(() => {
window.removeEventListener("scroll", onScroll);
if (scrollEndTimer !== null) {
clearTimeout(scrollEndTimer);
}
resolve();
}, 1e3);
} else {
this.scrollParentContainersToShowElement(element, scrollableParents).then(() => {
if (!this.isElementInViewport(element)) {
element.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "center"
});
}
setTimeout(() => {
resolve();
}, 200);
});
}
});
}
/**
* Scrolls parent containers to make the element visible
* @param element - The target element
* @param scrollableParents - Array of scrollable parent containers
* @returns Promise that resolves when scrolling is complete
*/
async scrollParentContainersToShowElement(element, scrollableParents) {
return new Promise((resolve) => {
let completedScrolls = 0;
const totalScrolls = scrollableParents.length;
if (totalScrolls === 0) {
resolve();
return;
}
const onScrollComplete = () => {
completedScrolls++;
if (completedScrolls >= totalScrolls) {
resolve();
}
};
scrollableParents.forEach((parent, index) => {
const elementRect = element.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const scrollTop = parent.scrollTop;
const scrollLeft = parent.scrollLeft;
const targetScrollTop = scrollTop + (elementRect.top - parentRect.top) - parentRect.height / 2 + elementRect.height / 2;
const targetScrollLeft = scrollLeft + (elementRect.left - parentRect.left) - parentRect.width / 2 + elementRect.width / 2;
parent.scrollTo({
top: Math.max(0, targetScrollTop),
left: Math.max(0, targetScrollLeft),
behavior: "smooth"
});
let scrollEndTimer = null;
const onParentScroll = () => {
if (scrollEndTimer !== null) {
clearTimeout(scrollEndTimer);
}
scrollEndTimer = window.setTimeout(() => {
parent.removeEventListener("scroll", onParentScroll);
onScrollComplete();
}, 100);
};
parent.addEventListener("scroll", onParentScroll);
setTimeout(() => {
parent.removeEventListener("scroll", onParentScroll);
if (scrollEndTimer !== null) {
clearTimeout(scrollEndTimer);
}
onScrollComplete();
}, 800);
});
});
}
/**
* Finds an element and scrolls it into view if necessary
* @param selector - CSS selector
* @returns - Promise that resolves with the element or null
*/
async findElementAndScrollIntoView(selector) {
const element = this.findElement(selector);
if (!element) {
return null;
}
if (!this.isElementInViewport(element)) {
await this.scrollElementIntoView(element);
}
return element;
}
/**
* Creates the virtual cursor element
*/
create() {
this.cursor = document.createElement("div");
this.cursor.style.position = "fixed";
this.cursor.style.top = "0";
this.cursor.style.left = "0";
this.cursor.style.width = "36px";
this.cursor.style.height = "36px";
this.cursor.style.zIndex = "9999999";
this.cursor.style.pointerEvents = "none";
this.cursor.style.display = "none";
this.cursor.style.transform = "translate(-50%, -50%)";
this.cursor.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#ff7614" stroke-width="2">
<path d="M3 3L10.5 21L13.5 13.5L21 10.5L3 3Z" fill="#ff7614" />
</svg>
`;
this.selectionElement = document.createElement("div");
this.selectionElement.style.position = "fixed";
this.selectionElement.style.pointerEvents = "none";
this.selectionElement.style.display = "none";
this.selectionElement.style.zIndex = "9999998";
document.body.appendChild(this.cursor);
document.body.appendChild(this.selectionElement);
this.flashlightOverlay = document.createElement("div");
this.flashlightOverlay.style.position = "fixed";
this.flashlightOverlay.style.top = "0";
this.flashlightOverlay.style.left = "0";
this.flashlightOverlay.style.width = "100vw";
this.flashlightOverlay.style.height = "100vh";
this.flashlightOverlay.style.pointerEvents = "none";
this.flashlightOverlay.style.zIndex = "999997";
this.flashlightOverlay.style.display = "none";
this.flashlightOverlay.style.background = "radial-gradient(circle 150px at 50% 50%, transparent 0%, rgba(0, 0, 0, 0.4) 100%)";
document.body.appendChild(this.flashlightOverlay);
this.boundScrollHandler = this.handleScroll.bind(this);
window.addEventListener("scroll", this.boundScrollHandler, { passive: true });
}
/**
* Handles scrolling to keep the cursor positioned on the target element
*/
handleScroll() {
if (!this.shouldShowCursor || !this.currentTargetElement || this.scrollThrottleTimeout !== null) {
return;
}
this.scrollThrottleTimeout = window.setTimeout(() => {
var _a, _b, _c, _d;
this.scrollThrottleTimeout = null;
if (this.animationFrameId !== null || !this.currentTargetElement) {
return;
}
const targetRect = this.currentTargetElement.getBoundingClientRect();
const newX = targetRect.left + targetRect.width / 2;
const newY = targetRect.top + targetRect.height / 2;
this.show(newX, newY);
this.lastCursorX = newX;
this.lastCursorY = newY;
if (this.selectionElement) {
if (this.isSelectionMode) {
const padding = typeof ((_b = (_a = this.currentAnimation) == null ? void 0 : _a.selectionStyles) == null ? void 0 : _b.padding) === "number" ? this.currentAnimation.selectionStyles.padding : ((_d = (_c = this.currentAnimation) == null ? void 0 : _c.selectionStyles) == null ? void 0 : _d.padding) ? parseInt(this.currentAnimation.selectionStyles.padding, 10) : this.selectionPadding;
const left = Math.floor(targetRect.left - padding);
const top = Math.floor(targetRect.top - padding);
const width = Math.ceil(targetRect.width + padding * 2);
const height = Math.ceil(targetRect.height + padding * 2);
this.selectionElement.style.setProperty("--sf-selection-left", `${left}px`);
this.selectionElement.style.setProperty("--sf-selection-top", `${top}px`);
this.selectionElement.style.setProperty("--sf-selection-width", `${width}px`);
this.selectionElement.style.setProperty("--sf-selection-height", `${height}px`);
this.dragStartX = left;
this.dragStartY = top;
this.dragEndX = left + width;
this.dragEndY = top + height;
this.updateFlashlightWithCutout(left, top, width, height);
}
}
}, 100);
}
/**
* Checks if autoplay is blocked and cursor should be disabled
* @returns - Whether autoplay is blocked
*/
isAutoplayBlocked() {
const state = saltfishStore.getState();
return state.currentState === "autoplayBlocked";
}
/**
* Sets whether the cursor should be shown based on step configuration
* @param shouldShow - Whether the cursor should be shown
*/
setShouldShowCursor(shouldShow) {
if (this.isAutoplayBlocked()) {
this.shouldShowCursor = false;
this.hide();
return;
}
this.shouldShowCursor = shouldShow;
if (!shouldShow) {
this.hide();
} else if (!this.isFirstAnimation) {
this.show(this.lastCursorX, this.lastCursorY);
}
}
/**
* Shows the cursor and flashlight at a specific position
* Note: Will only show if shouldShowCursor is true and autoplay is not blocked
* @param x - X coordinate
* @param y - Y coordinate
*/
show(x, y) {
if (!this.shouldShowCursor) {
return;
}
if (this.isAutoplayBlocked()) {
return;
}
this.lastCursorX = x;
this.lastCursorY = y;
if (this.cursor) {
this.cursor.style.display = "block";
this.cursor.style.top = `${y}px`;
this.cursor.style.left = `${x}px`;
}
if (this.flashlightOverlay) {
this.flashlightOverlay.style.display = "block";
this.flashlightOverlay.style.background = `radial-gradient(circle 150px at ${x}px ${y}px, transparent 0%, rgba(0, 0, 0, 0.4) 100%)`;
}
}
/**
* Hides the cursor and flashlight
* @deprecated Use setShouldShowCursor(false) instead for better state management
*/
hide() {
if (this.cursor) {
this.cursor.style.display = "none";
}
if (this.selectionElement) {
this.selectionElement.style.display = "none";
}
if (this.flashlightOverlay) {
this.flashlightOverlay.style.display = "none";
this.resetFlashlightOverlay();
}
}
/**
* Resets the flashlight overlay to its original state without cutouts
*/
resetFlashlightOverlay() {
if (!this.flashlightOverlay) return;
this.flashlightOverlay.style.clipPath = "none";
const x = this.lastCursorX;
const y = this.lastCursorY;
this.flashlightOverlay.style.background = `radial-gradient(circle 150px at ${x}px ${y}px, transparent 0%, rgba(0, 0, 0, 0.4) 100%)`;
}
/**
* Updates the selection area position and size
* @param targetElement - Element to select
* @param styles - Optional selection styles
*/
updateSelectionArea(targetElement, styles) {
if (!this.selectionElement) return;
const rect = targetElement.getBoundingClientRect();
const paddingValue = typeof (styles == null ? void 0 : styles.padding) === "number" ? styles.padding : (styles == null ? void 0 : styles.padding) ? parseInt(styles.padding, 10) : this.selectionPadding;
this.selectionElement.style.display = "block";
this.selectionElement.style.left = `${rect.left - paddingValue}px`;
this.selectionElement.style.top = `${rect.top - paddingValue}px`;
this.selectionElement.style.width = `${rect.width + paddingValue * 2}px`;
this.selectionElement.style.height = `${rect.height + paddingValue * 2}px`;
if (styles) {
if (styles.borderColor) this.selectionElement.style.borderColor = styles.borderColor;
if (styles.borderWidth) this.selectionElement.style.borderWidth = styles.borderWidth;
if (styles.borderRadius) this.selectionElement.style.borderRadius = styles.borderRadius;
}
}
/**
* Animates the cursor along a path
* @param animation - Animation configuration
*/
async animate(animation) {
if (this.isAutoplayBlocked()) {
return;
}
this.setShouldShowCursor(true);
this.stopAnimation();
this.resetFlashlightOverlay();
if (this.selectionElement) {
this.selectionElement.style.display = "none";
}
if (!(animation == null ? void 0 : animation.targetSelector)) {
console.warn("CursorManager: No targetSelector provided in animation");
return;
}
if (this.targetMutationObserver) {
this.targetMutationObserver.disconnect();
this.targetMutationObserver = null;
}
const targetElement = await this.findElementAndScrollIntoView(animation.targetSelector);
if (!targetElement) {
console.warn("CursorManager: Target element not found in animate:", animation.targetSelector);
this.targetMutationObserver = new MutationObserver(async (_, observer) => {
const el = await this.findElementAndScrollIntoView(animation.targetSelector);
if (el) {
observer.disconnect();
this.targetMutationObserver = null;
await this.animate(animation);
}
});
this.targetMutationObserver.observe(document.body, { childList: true, subtree: true });
const periodicCheck = setInterval(() => {
if (!this.targetMutationObserver) {
clearInterval(periodicCheck);
return;
}
const found = this.findElement(animation.targetSelector);
if (found) {
clearInterval(periodicCheck);
this.targetMutationObserver.disconnect();
this.targetMutationObserver = null;
this.animate(animation);
}
}, 1e3);
return;
}
this.currentTargetElement = targetElement;
this.addScrollListenersToParents(targetElement);
const targetRect = targetElement.getBoundingClientRect();
if (this.isFirstAnimation) {
this.startX = 50;
this.startY = 50;
this.isFirstAnimation = false;
} else {
this.startX = this.lastCursorX;
this.startY = this.lastCursorY;
}
const resolvedAnimation = {
...animation,
mode: animation.mode || "selection"
};
this.isSelectionMode = resolvedAnimation.mode === "selection";
if (this.isSelectionMode) {
this.handleSelectionMode(resolvedAnimation, targetRect);
return;
} else {
const horizontalOffset = 16;
this.targetX = targetRect.left + targetRect.width / 2 + horizontalOffset;
const verticalOffset = 16;
this.targetY = targetRect.top + targetRect.height / 2 + verticalOffset;
if (this.selectionElement) {
this.selectionElement.style.display = "none";
}
}
if (this.targetX !== null && this.targetY !== null && this.startX !== null && this.startY !== null) {
this.totalDistance = Math.sqrt(
Math.pow(this.targetX - this.startX, 2) + Math.pow(this.targetY - this.startY, 2)
);
} else {
this.totalDistance = 100;
}
this.calculateControlPoint();
this.currentAnimation = { ...resolvedAnimation };
const safeStartX = this.startX !== null ? this.startX : 0;
const safeStartY = this.startY !== null ? this.startY : 0;
this.show(safeStartX, safeStartY);
this.animationStartTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.animationFrame.bind(this));
}
/**
* Handles selection mode setup and animation
*/
handleSelectionMode(animation, targetRect) {
var _a, _b;
const padding = typeof ((_a = animation.selectionStyles) == null ? void 0 : _a.padding) === "number" ? animation.selectionStyles.padding : ((_b = animation.selectionStyles) == null ? void 0 : _b.padding) ? parseInt(animation.selectionStyles.padding, 10) : this.selectionPadding;
this.dragStartX = Math.floor(targetRect.left - padding);
this.dragStartY = Math.floor(targetRect.top - padding);
this.dragEndX = Math.ceil(targetRect.right + padding);
this.dragEndY = Math.ceil(targetRect.bottom + padding);
this.dragPhase = "move-to-start";
const horizontalOffset = 8;
const verticalOffset = 8;
this.targetX = this.dragStartX + horizontalOffset;
this.targetY = this.dragStartY + verticalOffset;
if (this.targetX !== null && this.targetY !== null && this.startX !== null && this.startY !== null) {
this.totalDistance = Math.sqrt(
Math.pow(this.targetX - this.startX, 2) + Math.pow(this.targetY - this.startY, 2)
);
} else {
this.totalDistance = 100;
}
if (this.selectionElement) {
if (animation.selectionStyles) {
if (animation.selectionStyles.borderColor) {
this.selectionElement.style.setProperty("--sf-selection-border-color", animation.selectionStyles.borderColor);
}
if (animation.selectionStyles.borderWidth) {
this.selectionElement.style.setProperty("--sf-selection-border-width", animation.selectionStyles.borderWidth);
}
if (animation.selectionStyles.borderRadius) {
this.selectionElement.style.setProperty("--sf-selection-border-radius", animation.selectionStyles.borderRadius);
}
}
this.selectionElement.style.display = "none";
}
this.calculateControlPoint();
this.currentAnimation = { ...animation };
const safeStartX = this.startX !== null ? this.startX : 0;
const safeStartY = this.startY !== null ? this.startY : 0;
this.show(safeStartX, safeStartY);
this.animationStartTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.dragAnimationFrame.bind(this));
}
/**
* Animation frame handler for drag selection
*/
dragAnimationFrame(timestamp) {
var _a, _b;
if (!this.animationStartTime || !this.currentAnimation) {
this.stopAnimation();
return;
}
const previousTargetX = this.targetX || 0;
const previousTargetY = this.targetY || 0;
if (this.currentTargetElement && this.dragPhase !== "release") {
const currentRect = this.currentTargetElement.getBoundingClientRect();
const padding = typeof ((_a = this.currentAnimation.selectionStyles) == null ? void 0 : _a.padding) === "number" ? this.currentAnimation.selectionStyles.padding : ((_b = this.currentAnimation.selectionStyles) == null ? void 0 : _b.padding) ? parseInt(this.currentAnimation.selectionStyles.padding, 10) : this.selectionPadding;
this.dragStartX = Math.floor(currentRect.left - padding);
this.dragStartY = Math.floor(currentRect.top - padding);
this.dragEndX = Math.ceil(currentRect.right + padding);
this.dragEndY = Math.ceil(currentRect.bottom + padding);
if (this.dragPhase === "move-to-start") {
const horizontalOffset = 8;
const verticalOffset = 8;
this.targetX = this.dragStartX + horizontalOffset;
this.targetY = this.dragStartY + verticalOffset;
const targetDeltaX = Math.abs(previousTargetX - this.targetX);
const targetDeltaY = Math.abs(previousTargetY - this.targetY);
if (targetDeltaX > 10 || targetDeltaY > 10) {
this.calculateControlPoint();
} else {
if (this.startX !== null && this.startY !== null) {
this.totalDistance = Math.sqrt(
Math.pow(this.targetX - this.startX, 2) + Math.pow(this.targetY - this.startY, 2)
);
}
}
}
}
if (this.dragPhase === "move-to-start") {
if (!this.startX || !this.startY || !this.targetX || !this.targetY || !this.controlPointX || !this.controlPointY) {
this.stopAnimation();
return;
}
const elapsed = timestamp - this.animationStartTime;
const distanceTraveled = this.cursorSpeed * elapsed;
if (distanceTraveled >= this.totalDistance) {
this.show(this.targetX, this.targetY);
this.lastCursorX = this.targetX;
this.lastCursorY = this.targetY;
this.dragPhase = "dragging";
this.dragAnimationStartTime = performance.now();
if (this.cursor) {
this.cursor.style.transform = "scale(0.9) translate(-50%, -50%)";
this.cursor.style.opacity = "0.9";
}
if (this.selectionElement) {
this.selectionElement.style.left = `${this.dragStartX}px`;
this.selectionElement.style.top = `${this.dragStartY}px`;
this.selectionElement.style.width = "0px";
this.selectionElement.style.height = "0px";
this.selectionElement.style.display = "block";
}
this.animationFrameId = requestAnimationFrame(this.dragAnimationFrame.bind(this));
return;
}
let progress = this.totalDistance > 0 ? distanceTraveled / this.totalDistance : 1;
progress = Math.min(progress, 1);
const easedProgress = 0.5 - 0.5 * Math.cos(progress * Math.PI);
const t = easedProgress;
const oneMinusT = 1 - t;
const currentX = Math.pow(oneMinusT, 2) * this.startX + 2 * oneMinusT * t * this.controlPointX + Math.pow(t, 2) * this.targetX;
const currentY = Math.pow(oneMinusT, 2) * this.startY + 2 * oneMinusT * t * this.controlPointY + Math.pow(t, 2) * this.targetY;
this.show(currentX, currentY);
} else if (this.dragPhase === "dragging") {
if (!this.dragAnimationStartTime || !this.dragStartX || !this.dragStartY || !this.dragEndX || !this.dragEndY) {
this.stopAnimation();
return;
}
const dragDuration = 1e3;
const elapsed = timestamp - this.dragAnimationStartTime;
let progress = Math.min(elapsed / dragDuration, 1);
const easedProgress = 0.5 - 0.5 * Math.cos(progress * Math.PI);
const horizontalOffset = 8;
const verticalOffset = 8;
const currentX = this.dragStartX + (this.dragEndX - this.dragStartX) * easedProgress + horizontalOffset;
const currentY = this.dragStartY + (this.dragEndY - this.dragStartY) * easedProgress + verticalOffset;
this.show(currentX, currentY);
if (this.selectionElement) {
let width = Math.ceil(Math.abs(currentX - horizontalOffset - this.dragStartX));
let height = Math.ceil(Math.abs(currentY - verticalOffset - this.dragStartY));
let left = Math.floor(Math.min(this.dragStartX, currentX - horizontalOffset));
let top = Math.floor(Math.min(this.dragStartY, currentY - verticalOffset));
this.selectionElement.style.left = `${left}px`;
this.selectionElement.style.top = `${top}px`;
this.selectionElement.style.width = `${width}px`;
this.selectionElement.style.height = `${height}px`;
this.selectionElement.style.display = "block";
this.updateFlashlightWithCutout(left, top, width, height);
}
if (progress >= 1) {
this.dragPhase = "release";
if (this.cursor) {
this.cursor.style.transform = "translate(-50%, -50%)";
this.cursor.style.opacity = "1";
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.animationStartTime = null;
this.dragAnimationStartTime = null;
}
}
if (this.dragPhase !== "release") {
this.animationFrameId = requestAnimationFrame(this.dragAnimationFrame.bind(this));
}
}
/**
* Updates the flashlight overlay to exclude the selection area
*/
updateFlashlightWithCutout(left, top, width, height) {
if (!this.flashlightOverlay) return;
const clipPath = `
polygon(
0% 0%, /* Top-left of screen */
100% 0%, /* Top-right of screen */
100% 100%, /* Bottom-right of screen */
0% 100%, /* Bottom-left of screen */
0% ${top}px, /* Begin cutout: bottom-left of cutout */
${left}px ${top}px, /* Bottom-left of cutout */
${left}px ${top + height}px, /* Top-left of cutout */
${left + width}px ${top + height}px, /* Top-right of cutout */
${left + width}px ${top}px, /* Bottom-right of cutout */
0% ${top}px /* Close cutout path */
)
`;
this.flashlightOverlay.style.clipPath = clipPath;
const x = this.lastCursorX;
const y = this.lastCursorY;
this.flashlightOverlay.style.background = `radial-gradient(circle 150px at ${x}px ${y}px, transparent 0%, rgba(0, 0, 0, 0.4) 100%)`;
}
/**
* Calculates a control point for curved cursor movement
*/
calculateControlPoint() {
if (!this.startX || !this.startY || !this.targetX || !this.targetY) {
console.warn("CursorManager: Missing start or target position for control point calculation", {
startX: this.startX,
startY: this.startY,
targetX: this.targetX,
targetY: this.targetY
});
return;
}
const midpointX = (this.startX + this.targetX) / 2;
const midpointY = (this.startY + this.targetY) / 2;
const distance = Math.sqrt(
Math.pow(this.targetX - this.startX, 2) + Math.pow(this.targetY - this.startY, 2)
);
this.totalDistance = distance;
const maxOffset = distance * 0.2;
const randomOffset = (Math.random() - 0.5) * maxOffset * 2;
const vectorX = this.targetX - this.startX;
const vectorY = this.targetY - this.startY;
const perpVectorX = -vectorY;
const perpVectorY = vectorX;
const perpLength = Math.sqrt(perpVectorX * perpVectorX + perpVectorY * perpVectorY);
if (perpLength === 0) {
this.controlPointX = midpointX + 5;
this.controlPointY = midpointY + 5;
} else {
const normalizedPerpX = perpVectorX / perpLength;
const normalizedPerpY = perpVectorY / perpLength;
this.controlPointX = midpointX + normalizedPerpX * randomOffset;
this.controlPointY = midpointY + normalizedPerpY * randomOffset;
}
}
/**
* Moves the cursor to a DOM element
* @param selector - DOM element selector
* @param mode - Optional mode for cursor (pointer or selection)
* @param selectionStyles - Optional styles for selection mode
*/
async moveToElement(selector, mode, selectionStyles) {
if (this.isAutoplayBlocked()) {
return;
}
if (!this.shouldShowCursor) {
return;
}
this.stopAnimation();
this.resetFlashlightOverlay();
if (this.targetMutationObserver) {
this.targetMutationObserver.disconnect();
this.targetMutationObserver = null;
}
const targetElement = await this.findElementAndScrollIntoView(selector);
if (!targetElement) {
console.warn("CursorManager: Target element not found:", selector);
this.targetMutationObserver = new MutationObserver(async (_, observer) => {
const el = await this.findElementAndScrollIntoView(selector);
if (el) {
observer.disconnect();
this.targetMutationObserver = null;
await this.moveToElement(selector, mode, selectionStyles);
}
});
this.targetMutationObserver.observe(document.body, { childList: true, subtree: true });
const periodicCheck = setInterval(() => {
if (!this.targetMutationObserver) {
clearInterval(periodicCheck);
return;
}
const found = this.findElement(selector);
if (found) {
clearInterval(periodicCheck);
this.targetMutationObserver.disconnect();
this.targetMutationObserver = null;
this.moveToElement(selector, mode, selectionStyles);
}
}, 1e3);
return;
}
targetElement.getBoundingClientRect();
this.currentTargetElement = targetElement;
this.addScrollListenersToParents(targetElement);
const animation = {
targetSelector: selector,
// Store the selector for clicking after animation
mode: mode || "selection",
// Default to selection mode
selectionStyles
};
this.animate(animation);
}
/**
* Simulates a click action with the cursor
*/
click() {
if (this.isAutoplayBlocked()) {
return;
}
if (!this.shouldShowCursor) {
return;
}
if (this.cursor) {
this.cursor.style.transform = "scale(0.8) translate(-50%, -50%)";
this.cursor.style.opacity = "0.8";
setTimeout(() => {
if (this.cursor) {
if (!this.shouldShowCursor || this.isAutoplayBlocked()) {
this.hide();
return;
}
this.cursor.style.transform = "translate(-50%, -50%)";
this.cursor.style.opacity = "1";
} else {
console.warn("CursorManager: Cursor element was removed during click animation");
}
}, 300);
} else {
console.warn("CursorManager: Cannot apply click animation - cursor element is null");
}
}
/**
* Stops the current animation
*/
stopAnimation() {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.animationStartTime = null;
this.currentAnimation = null;
this.controlPointX = null;
this.controlPointY = null;
this.selectionStartWidth = null;
this.selectionStartHeight = null;
this.selectionTargetWidth = null;
this.selectionTargetHeight = null;
this.dragPhase = "move-to-start";
this.dragAnimationStartTime = null;
if (this.cursor) {
this.cursor.style.transform = "translate(-50%, -50%)";
this.cursor.style.opacity = "1";
}
this.resetFlashlightOverlay();
if (this.selectionElement) {
this.selectionElement.style.display = "none";
}
}
/**
* Gets the current X position of the cursor
*/
getCursorX() {
if (this.cursor) {
const transform = this.cursor.style.transform;
const match = transform.match(/translate\((\d+)px, \d+px\)/);
return match ? parseInt(match[1], 10) : this.lastCursorX;
}
return this.lastCursorX;
}
/**
* Gets the current Y position of the cursor
*/
getCursorY() {
if (this.cursor) {
const transform = this.cursor.style.transform;
const match = transform.match(/translate\(\d+px, (\d+)px\)/);
return match ? parseInt(match[1], 10) : this.lastCursorY;
}
return this.lastCursorY;
}
/**
* Resets cursor state for first animation
*/
resetFirstAnimation() {
this.isFirstAnimation = true;
}
/**
* Cleans up resources used by the cursor manager
*/
destroy() {
this.stopAnimation();
if (this.boundScrollHandler) {
window.removeEventListener("scroll", this.boundScrollHandler);
this.boundScrollHandler = null;
}
this.removeScrollListenersFromParents();
if (this.scrollThrottleTimeout !== null) {
window.clearTimeout(this.scrollThrottleTimeout);
this.scrollThrottleTimeout = null;
}
if (this.cursor && this.cursor.parentNode) {
this.cursor.remove();
this.cursor = null;
}
if (this.selectionElement && this.selectionElement.parentNode) {
this.selectionElement.remove();
this.selectionElement = null;
}
if (this.flashlightOverlay && this.flashlightOverlay.parentNode) {
this.flashlightOverlay.remove();
this.flashlightOverlay = null;
}
this.isFirstAnimation = true;
this.shouldShowCursor = false;
this.currentTargetElement = null;
this.isSelectionMode = false;
if (this.targetMutationObserver) {
this.targetMutationObserver.disconnect();
this.targetMutationObserver = null;
}
document.documentElement.style.removeProperty("--sf-cursor-x");
document.documentElement.style.removeProperty("--sf-cursor-y");
}
/**
* Animation frame handler
*/
animationFrame(timestamp) {
var _a, _b, _c;
if (!this.animationStartTime || !this.currentAnimation || !this.startX || !this.startY || !this.targetX || !this.targetY || !this.controlPointX || !this.controlPointY) {
console.warn("CursorManager: Animation frame missing essential data, stopping animation");
this.stopAnimation();
return;
}
const previousTargetX = this.targetX || 0;
const previousTargetY = this.targetY || 0;
if (this.currentTargetElement) {
const currentRect = this.currentTargetElement.getBoundingClientRect();
if (this.isSelectionMode) {
const padding = typeof ((_a = this.currentAnimation.selectionStyles) == null ? void 0 : _a.padding) === "number" ? this.currentAnimation.selectionStyles.padding : ((_b = this.currentAnimation.selectionStyles) == null ? void 0 : _b.padding) ? parseInt(this.currentAnimation.selectionStyles.padding, 10) : this.selectionPadding;
this.dragStartX = Math.floor(currentRect.left - padding);
this.dragStartY = Math.floor(currentRect.top - padding);
this.dragEndX = Math.ceil(currentRect.right + padding);
this.dragEndY = Math.ceil(currentRect.bottom + padding);
} else {
const horizontalOffset = 16;
const verticalOffset = 16;
this.targetX = currentRect.left + currentRect.width / 2 + horizontalOffset;
this.targetY = currentRect.top + currentRect.height / 2 + verticalOffset;
const targetDeltaX = Math.abs(previousTargetX - this.targetX);
const targetDeltaY = Math.abs(previousTargetY - this.targetY);
if (targetDeltaX > 10 || targetDeltaY > 10) {
this.calculateControlPoint();
} else {
if (this.startX !== null && this.startY !== null) {
this.totalDistance = Math.sqrt(
Math.pow(this.targetX - this.startX, 2) + Math.pow(this.targetY - this.startY, 2)
);
}
}
}
}
const elapsed = timestamp - this.animationStartTime;
const distanceTraveled = this.cursorSpeed * elapsed;
if (distanceTraveled >= this.totalDistance) {
this.show(this.targetX, this.targetY);
this.lastCursorX = this.targetX;
this.lastCursorY = this.targetY;
if (this.isSelectionMode && this.currentTargetElement && this.selectionElement) {
this.updateSelectionArea(this.currentTargetElement, this.currentAnimation.selectionStyles);
} else {
this.click();
}
setTimeout(() => {
this.stopAnimation();
}, 400);
return;
}
let progress = this.totalDistance > 0 ? distanceTraveled / this.totalDistance : 1;
progress = Math.min(progress, 1);
const easedProgress = 0.5 - 0.5 * Math.cos(progress * Math.PI);
const t = easedProgress;
const oneMinusT = 1 - t;
const currentX = Math.pow(oneMinusT, 2) * this.startX + 2 * oneMinusT * t * this.controlPointX + Math.pow(t, 2) * this.targetX;
const currentY = Math.pow(oneMinusT, 2) * this.startY + 2 * oneMinusT * t * this.controlPointY + Math.pow(t, 2) * this.targetY;
this.show(currentX, currentY);
if (this.isSelectionMode && this.selectionElement && this.selectionStartWidth !== null && this.selectionStartHeight !== null && this.selectionTargetWidth !== null && this.selectionTargetHeight !== null) {
const currentWidth = this.selectionStartWidth + (this.selectionTargetWidth - this.selectionStartWidth) * easedProgress;
const currentHeight = this.selectionStartHeight + (this.selectionTargetHeight - this.selectionStartHeight) * easedProgress;
const targetRect = (_c = this.currentTargetElement) == null ? void 0 : _c.getBoundingClientRect();
if (targetRect && this.currentTargetElement) {
this.selectionElement.style.left = `${targetRect.left + targetRect.width / 2 - currentWidth / 2}px`;
this.selectionElement.style.top = `${targetRect.top + targetRect.height / 2 - currentHeight / 2}px`;
this.selectionElement.style.width = `${currentWidth}px`;
this.selectionElement.style.height = `${currentHeight}px`;
if (easedProgress > 0.9) {
this.updateSelectionArea(this.currentTargetElement, this.currentAnimation.selectionStyles);
}
}
}
this.animationFrameId = requestAnimationFrame(this.animationFrame.bind(this));
}
/**
* Sets the color of the cursor SVG
* @param color - The color to set (hex, rgb, etc)
*/
setColor(color) {
if (this.cursor) {
const svg = this.cursor.querySelector("svg");
if (svg) {
svg.setAttribute("stroke", color);
const path = svg.querySelector("path");
if (path) {
path.setAttribute("fill", color);
} else {
console.warn("[CursorManager.setColor] No path found in SVG");
}
} else {
console.warn("[CursorManager.setColor] No SVG found in cursor");
}
} else {
console.warn("[CursorManager.setColor] No cursor element");
}
}
}
class InteractionManager {
constructor() {
__publicField(this, "container", null);
__publicField(this, "buttons", []);
__publicField(this, "buttonContainer", null);
__publicField(this, "domEventListeners", /* @__PURE__ */ new Map());
__publicField(this, "storeUnsubscribe", null);
}
/**
* Creates interaction elements
* @param container - The container element for interactions
*/
create(container) {
this.container = container;
this.storeUnsubscribe = useSaltfishStore.subscribe((state, prevState) => {
if (state.isMinimized !== (prevState == null ? void 0 : prevState.isMinimized)) {
this.updateButtonPositions();
}
});
}
/**
* Creates interactive buttons positioned within the shadow DOM
* @param buttons - Button configurations
*/
createButtons(buttons) {
if (!this.container) {
return;
}
this.clearButtons();
this.buttonContainer = document.createElement("div");
this.buttonContainer.className = "sf-choice-buttons-container";
this.container.appendChild(this.buttonContainer);
buttons.forEach((buttonConfig) => {
const button = document.createElement("button");
button.textContent = buttonConfig.text;
button.dataset.buttonId = buttonConfig.id;
button.className = `sf-choice-button sf-choice-button--${buttonConfig.action.type}`;
if (buttonConfig.style) {
Object.entries(buttonConfig.style).forEach(([prop, value]) => {
button.style.setProperty(`--custom-${prop}`, String(value));
});
}
button.addEventListener("click", async (event) => {
await this.handleButtonClick(event, buttonConfig);
});
if (this.buttonContainer) {
this.buttonContainer.appendChild(button);
this.buttons.push(button);
log(`InteractionManager: Added button '${buttonConfig.id}'`);
}
});
const store = useSaltfishStore.getState();
if (store.isMinimized) {
this.buttonContainer.style.display = "none";
}
}
/**
* Clears all interactive buttons
*/
clearButtons() {
this.buttons.forEach((button) => {
if (button.parentElement) {
button.parentElement.removeChild(button);
}
});
if (this.buttonContainer && this.buttonContainer.parentElement) {
this.buttonContainer.parentElement.removeChild(this.buttonContainer);
}
this.buttons = [];
this.buttonContainer = null;
}
/**
* Updates button visibility and positions based on player state
*/
updateButtonPositions() {
if (!this.buttonContainer || this.buttons.length === 0) {
return;
}
const store = useSaltfishStore.getState();
if (store.isMinimized) {
this.buttonContainer.style.display = "none";
return;
} else {
this.buttonContainer.style.display = "flex";
}
this.buttonContainer.className = "sf-choice-buttons-container";
}
/**
* Sets up DOM interactions
* @param interactions - DOM interaction configurations
*/
setupDOMInteractions(interactions) {
this.clearDOMInteractions();
interactions.forEach((interaction, index) => {
try {
const elements = document.querySelectorAll(interaction.selector);
if (elements.length === 0) {
log(`InteractionManager: No elements found matching selector "${interaction.selector}"`);
return;
}
log(`InteractionManager: Found ${elements.length} elements matching selector "${interaction.selector}"`);
elements.forEach((element, elementIndex) => {
const htmlElement = element;
const listenerId = `dom-interaction-${index}-${elementIndex}-${Date.now()}`;
const listener = (event) => {
this.handleDOMInteraction(event, interaction);
};
htmlElement.addEventListener(interaction.action, listener);
this.domEventListeners.set(listenerId, {
element: htmlElement,
listener,
type: interaction.action
});
log(`InteractionManager: Added ${interaction.action} listener to element with selector "${interaction.selector}"`);
});
} catch (error2) {
console.error(`InteractionManager: Error setting up DOM interaction for selector "${interaction.selector}":`, error2);
}
});
}
/**
* Clears all DOM interaction listeners
*/
clearDOMInteractions() {
this.domEventListeners.forEach(({ element, listener, type }) => {
try {
element.removeEventListener(type, listener);
} catch (error2) {
console.error("InteractionManager: Error removing event listener:", error2);
}
});
this.domEventListeners.clear();
}
/**
* Handles button clicks
* @param event - The click event
* @param buttonConfig - The button configuration
*/
async handleButtonClick(event, buttonConfig) {
log(`InteractionManager: Button click detected on button "${buttonConfig.id}"`);
event.preventDefault();
event.stopPropagation();
const store = useSaltfishStore.getState();
switch (buttonConfig.action.type) {
case "next":
store.play();
break;
case "goto":
log(`InteractionManager: Goto button clicked, going to step "${buttonConfig.action.target}"`);
store.goToStep(buttonConfig.action.target);
break;
case "url":
const isLastStep = this.isCurrentStepLast(store);
log(`InteractionManager: URL button clicked, opening "${buttonConfig.action.target}". Is last step: ${isLastStep}`);
if (isLastStep) {
await this.flushAnalytics();
store.goToStep("completed");
await new Promise((resolve) => setTimeout(resolve, 100));
}
window.open(buttonConfig.action.target, "_blank");
const { SaltfishPlayer: SaltfishPlayer2 } = await Promise.resolve().then(() => SaltfishPlayer$1);
const player = SaltfishPlayer2.getInstance();
if (player) {
player.destroy();
}
break;
case "dom":
log(`InteractionManager: DOM button clicked, interacting with "${buttonConfig.action.target}"`);
const targetElement = document.querySelector(buttonConfig.action.target);
if (targetElement) {
targetElement.click();
} else {
log(`InteractionManager: Target element not found: "${buttonConfig.action.target}"`);
}
break;
case "function":
log(`InteractionManager: Function button clicked, calling "${buttonConfig.action.target}"`);
try {
const func = new Function(`return ${buttonConfig.action.target}`)();
if (typeof func === "function") {
func();
} else {
log(`InteractionManager: "${buttonConfig.action.target}" is not a function`);
}
} catch (error2) {
console.error(`InteractionManager: Error calling function "${buttonConfig.action.target}":`, error2);
}
break;
default:
log(`InteractionManager: Unknown button action type: "${buttonConfig.action.type}"`);
break;
}
if (store.manifest) {
const analyticsData = {
buttonId: buttonConfig.id,
actionType: buttonConfig.action.type,
actionTarget: buttonConfig.action.target
};
log(`InteractionManager: Tracked button interaction: ${JSON.stringify(analyticsData)}`);
}
}
/**
* Handles DOM interactions
* @param event - The event
* @param interaction - The interaction configuration
*/
handleDOMInteraction(event, interaction) {
log(`InteractionManager: DOM interaction detected: ${interaction.action} on element with selector "${interaction.selector}"`);
if (interaction.waitFor) {
event.preventDefault();
event.stopPropagation();
log(`InteractionManager: Wait-for interaction "${interaction.selector}" detected, but interaction transitions are no longer supported`);
}
if (interaction.action === "input" && interaction.value) {
const inputElement = event.target;
if (inputElement) {
inputElement.value = interaction.value;
log(`InteractionManager: Set input value to "${interaction.value}"`);
}
}
const store = useSaltfishStore.getState();
if (store.manifest) {
const analyticsData = {
selector: interaction.selector,
action: interaction.action,
value: interaction.value
};
log(`InteractionManager: Tracked DOM interaction: ${JSON.stringify(analyticsData)}`);
}
}
/**
* Checks if the current step is the last step in the playlist
* @param store - The current store state
* @returns true if current step is the last step, false otherwise
*/
isCurrentStepLast(store) {
if (!store.manifest || !store.currentStepId || !store.manifest.steps) {
return false;
}
const steps = store.manifest.steps;
const currentStepIndex = steps.findIndex((step) => step.id === store.currentStepId);
return currentStepIndex === steps.length - 1;
}
/**
* Flushes analytics events to ensure they're sent before player destruction
*/
async flushAnalytics() {
try {
const { SaltfishPlayer: SaltfishPlayer2 } = await Promise.resolve().then(() => SaltfishPlayer$1);
const player = SaltfishPlayer2.getInstance();
if (player && player.analyticsManager) {
log("InteractionManager: Flushing analytics before URL button destruction");
const analyticsManager = player.analyticsManager;
if (typeof analyticsManager.flush === "function") {
await analyticsManager.flush();
}
}
} catch (error2) {
}
}
/**
* Cleans up resources used by the interaction manager
*/
destroy() {
if (this.storeUnsubscribe) {
this.storeUnsubscribe();
this.storeUnsubscribe = null;
}
this.clearButtons();
this.clearDOMInteractions();
this.container = null;
}
}
class AnalyticsManager {
// Default to enabled
/**
* Creates a new AnalyticsManager
* @param eventManager - Optional event manager to subscribe to events
*/
constructor(eventManager) {
__publicField(this, "config", null);
__publicField(this, "user", null);
__publicField(this, "eventQueue", []);
__publicField(this, "isSending", false);
__publicField(this, "flushInterval", null);
__publicField(this, "eventManager", null);
__publicField(this, "sessionId", null);
__publicField(this, "analyticsEnabled", true);
if (eventManager) {
this.setEventManager(eventManager);
}
}
/**
* Sets the event manager and subscribes to relevant events
* @param eventManager - Event manager instance
*/
setEventManager(eventManager) {
this.eventManager = eventManager;
this.subscribeToEvents();
}
/**
* Subscribe to relevant player events for analytics tracking
*/
subscribeToEvents() {
if (!this.eventManager) return;
this.eventManager.on("playerPaused", (_) => {
const store = this.getStore();
const runId = this.getRunId();
if ((store == null ? void 0 : store.manifest) && store.currentStepId && runId) {
this.trackEvent({
type: "playerPaused",
playlistId: store.manifest.id,
stepId: store.currentStepId,
runId,
timestamp: Date.now()
});
}
});
this.eventManager.on("playerResumed", (_) => {
const store = this.getStore();
const runId = this.getRunId();
if ((store == null ? void 0 : store.manifest) && store.currentStepId && runId) {
this.trackEvent({
type: "playerResumed",
playlistId: store.manifest.id,
stepId: store.currentStepId,
runId,
timestamp: Date.now()
});
}
});
this.eventManager.on("stepStarted", (event) => {
log(`AnalyticsManager: Step started event received - ${event.step.id}`);
this.trackStepStarted(event.playlist.id, event.step.id);
});
this.eventManager.on("stepEnded", (event) => {
log(`AnalyticsManager: Step ended event received - ${event.step.id}`);
this.trackStepComplete(event.playlist.id, event.step.id);
});
this.eventManager.on("playlistEnded", (event) => {
log(`AnalyticsManager: playlist ended event received - ${event.playlist.id}`);
this.trackPlaylistComplete(event.playlist.id);
});
this.eventManager.on("error", (event) => {
if (event.playlistId) {
this.trackError(
event.playlistId,
event.error,
event.stepId,
event.errorType
);
}
});
this.eventManager.on("playerMinimized", (_) => {
const store = this.getStore();
const runId = this.getRunId();
if ((store == null ? void 0 : store.manifest) && store.currentStepId && runId) {
this.trackEvent({
type: "playerMinimized",
playlistId: store.manifest.id,
stepId: store.currentStepId,
runId,
timestamp: Date.now()
});
}
});
this.eventManager.on("playerMaximized", (_) => {
const store = this.getStore();
const runId = this.getRunId();
if ((store == null ? void 0 : store.manifest) && store.currentStepId && runId) {
this.trackEvent({
type: "playerMaximized",
playlistId: store.manifest.id,
stepId: store.currentStepId,
runId,
timestamp: Date.now()
});
}
});
}
/**
* Helper to get the store state when needed
*/
getStore() {
try {
return useSaltfishStore.getState();
} catch (error2) {
console.error("Failed to access store:", error2);
return null;
}
}
/**
* Initializes the analytics manager
* @param config - Saltfish configuration
* @param sessionId - Unique session identifier
*/
initialize(config, sessionId) {
this.config = config;
this.analyticsEnabled = config.enableAnalytics !== false;
if (sessionId) {
this.sessionId = sessionId;
}
if (this.analyticsEnabled) {
this.flushInterval = window.setInterval(() => {
this.flushEvents();
}, 3e4);
}
}
/**
* Sets the current user
* @param user - User data
*/
setUser(user) {
this.user = user;
}
/**
* Tracks a playlist start event
* @param playlistId - playlist ID
*/
trackPlaylistStart(playlistId) {
if (!this.analyticsEnabled) {
return;
}
const runId = this.getRunId();
if (runId) {
this.trackEvent({
type: "playlistStart",
playlistId,
runId,
timestamp: Date.now()
});
}
}
/**
* Tracks a playlist completion event
* @param playlistId - playlist ID
*/
trackPlaylistComplete(playlistId) {
if (!this.analyticsEnabled) {
return;
}
const runId = this.getRunId();
if (runId) {
this.trackEvent({
type: "playlistComplete",
playlistId,
runId,
timestamp: Date.now()
});
}
}
/**
* Tracks a step started event
* @param playlistId - playlist ID
* @param stepId - Step ID
*/
trackStepStarted(playlistId, stepId) {
if (!this.analyticsEnabled) {
return;
}
const runId = this.getRunId();
if (runId) {
this.trackEvent({
type: "stepStarted",
playlistId,
stepId,
runId,
timestamp: Date.now()
});
}
}
/**
* Tracks a step completion event
* @param playlistId - playlist ID
* @param stepId - Step ID
*/
trackStepComplete(playlistId, stepId) {
if (!this.analyticsEnabled) {
return;
}
const runId = this.getRunId();
if (runId) {
this.trackEvent({
type: "stepComplete",
playlistId,
stepId,
runId,
timestamp: Date.now()
});
}
}
/**
* Tracks an interaction event
* @param playlistId - playlist ID
* @param stepId - Step ID
* @param interactionData - Interaction data
*/
trackInteraction(playlistId, stepId, interactionData) {
if (!this.analyticsEnabled) {
return;
}
const runId = this.getRunId();
if (runId) {
this.trackEvent({
type: "interaction",
playlistId,
stepId,
runId,
timestamp: Date.now(),
data: interactionData
});
}
}
/**
* Tracks an error event
* @param playlistId - playlist ID
* @param error - Error object
* @param stepId - Optional step ID
* @param errorType - Optional error type/category (e.g., 'playlist', 'video', 'network', 'initialization')
*/
trackError(playlistId, error2, stepId, errorType) {
if (!this.analyticsEnabled) {
return;
}
const runId = this.getRunId();
if (runId) {
this.trackEvent({
type: "error",
playlistId,
stepId,
runId,
timestamp: Date.now(),
data: {
message: error2.message,
stack: error2.stack,
errorType: errorType || "unknown"
}
});
}
}
/**
* Tracks a generic event
* @param event - Event data
*/
trackEvent(event) {
if (!this.analyticsEnabled) {
return;
}
this.eventQueue.push(event);
if (this.eventQueue.length >= 10) {
this.flushEvents();
}
}
/**
* Manually flush queued events immediately
* This is useful for ensuring events are sent before player destruction
*/
async flush() {
await this.flushEvents();
}
/**
* Sends queued events to the backend
*/
async flushEvents() {
if (this.isSending || this.eventQueue.length === 0 || !this.config || !this.analyticsEnabled) {
return;
}
this.isSending = true;
try {
const events = [...this.eventQueue];
this.eventQueue = [];
const payload = {
token: this.config.token,
sessionId: this.sessionId,
user: this.user,
events
};
const response = await fetch("https://player.saltfish.ai/analytics", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
this.eventQueue = [...events, ...this.eventQueue];
throw new Error(`Failed to send analytics events: ${response.statusText}`);
}
} catch (error2) {
console.error("Failed to send analytics events:", error2);
} finally {
this.isSending = false;
}
}
/**
* Cleans up resources used by the analytics manager
*/
destroy() {
if (this.analyticsEnabled) {
this.flushEvents();
}
if (this.flushInterval !== null) {
clearInterval(this.flushInterval);
this.flushInterval = null;
}
if (this.eventManager) {
this.eventManager = null;
}
this.config = null;
this.user = null;
this.eventQueue = [];
this.sessionId = null;
this.analyticsEnabled = true;
}
/**
* Gets the current runId from the player
* @returns The current runId or null if not available
*/
getRunId() {
try {
const player = SaltfishPlayer.getInstance();
return player.getRunId();
} catch (error2) {
return null;
}
}
}
class SessionRecordingManager {
/**
* Creates a new SessionRecordingManager
*/
constructor() {
__publicField(this, "initialized", false);
__publicField(this, "sessionId", null);
__publicField(this, "userId", null);
__publicField(this, "clientId", null);
__publicField(this, "isRecording", false);
}
/**
* Initializes the session recording manager
* @param config - Saltfish configuration
* @param sessionId - Persistent session ID from SessionManager (required)
* @param userId - Optional user ID to include in recording context
*/
initialize(config, sessionId, userId) {
this.sessionId = sessionId;
this.userId = userId || null;
this.clientId = config.token;
log(`SessionRecordingManager: Using persistent session ID: ${sessionId}${userId ? ` with userId: ${userId}` : ""} and clientId: ${this.clientId}`);
if (config.sessionRecording && !this.initialized) {
this.loadSaltfishRecordingScript().then(() => {
this.startRecording();
}).catch((error2) => {
});
}
}
/**
* Loads the Saltfish Session Recording script
*/
loadSaltfishRecordingScript() {
return new Promise((resolve, reject) => {
if (typeof window !== "undefined" && window.saltfishRecording) {
resolve();
return;
}
try {
if (typeof window !== "undefined") {
const script = document.createElement("script");
script.type = "text/javascript";
script.crossOrigin = "anonymous";
script.async = true;
script.src = "https://storage.saltfish.ai/recording/recorder.js";
script.onload = () => {
this.initialized = true;
log("SessionRecordingManager: Saltfish Recording script loaded");
resolve();
};
script.onerror = (error2) => {
reject(error2);
};
document.head.appendChild(script);
} else {
reject(new Error("Window is not defined"));
}
} catch (error2) {
reject(error2);
}
});
}
/**
* Starts the session recording with sessionId and userId only
* Recording is independent of playlist runs and runId
*/
startRecording() {
if (!this.initialized || !this.sessionId || this.isRecording) {
return;
}
if (typeof window !== "undefined" && window.saltfishRecording) {
log(`SessionRecordingManager: Starting recording with sessionId: ${this.sessionId}, userId: ${this.userId}, clientId: ${this.clientId}`);
const recordingConfig = {
sessionId: this.sessionId,
clientId: this.clientId
};
if (this.userId) {
recordingConfig.userId = this.userId;
}
window.saltfishRecording.start(recordingConfig);
this.isRecording = true;
}
}
/**
* Identifies a user for session recording
* @param user - User data
*/
identifyUser(user) {
this.userId = user.id;
if (!this.initialized) {
log(`SessionRecordingManager: User identified (${user.id}) but not fully initialized yet - userId stored for later use`);
return;
}
log(`SessionRecordingManager: Identifying user ${user.id} for session ${this.sessionId}`);
if (this.isRecording && typeof window !== "undefined" && window.saltfishRecording) {
window.saltfishRecording.stop();
this.isRecording = false;
const recordingConfig = {
sessionId: this.sessionId,
clientId: this.clientId,
userId: this.userId
};
window.saltfishRecording.start(recordingConfig);
this.isRecording = true;
log(`SessionRecordingManager: Restarted recording with user context, sessionId: ${this.sessionId}, userId: ${this.userId}, clientId: ${this.clientId}`);
}
}
/**
* Stops the current recording session
*/
stopRecording() {
if (this.isRecording && typeof window !== "undefined" && window.saltfishRecording) {
window.saltfishRecording.stop();
this.isRecording = false;
}
}
/**
* Gets the current recording status
* @returns true if currently recording, false otherwise
*/
isCurrentlyRecording() {
return this.isRecording;
}
}
class SessionManager {
constructor() {
__publicField(this, "sessionId");
__publicField(this, "currentRunId", null);
this.sessionId = this.getOrCreateSession();
log(`SessionManager: Initialized with sessionId: ${this.sessionId}`);
}
/**
* Gets or creates a persistent session ID
* @returns The current session ID
*/
getOrCreateSession() {
if (typeof window === "undefined") {
return this.generateUniqueId();
}
try {
const storedSession = localStorage.getItem(STORAGE_KEYS.SESSION);
if (storedSession) {
const sessionData = JSON.parse(storedSession);
const now = Date.now();
if (now - sessionData.lastActivity < TIMING.SESSION_EXPIRY) {
log(`SessionManager: Using existing session: ${sessionData.sessionId}`);
this.updateSessionActivity(sessionData.sessionId);
return sessionData.sessionId;
} else {
log(`SessionManager: Session expired, creating new session`);
}
}
} catch (error2) {
}
const newSessionId = this.generateUniqueId();
this.updateSessionActivity(newSessionId);
return newSessionId;
}
/**
* Updates the session activity timestamp
* @param sessionId - The session ID to update
*/
updateSessionActivity(sessionId) {
if (typeof window === "undefined") return;
try {
const sessionData = {
sessionId,
lastActivity: Date.now()
};
localStorage.setItem(STORAGE_KEYS.SESSION, JSON.stringify(sessionData));
log(`SessionManager: Updated session activity for: ${sessionId}`);
} catch (error2) {
}
}
/**
* Generates a unique ID using UUID v4 format
* @returns A unique ID string
*/
generateUniqueId() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === "x" ? r : r & 3 | 8;
return v.toString(16);
});
}
/**
* Gets the current session ID
* @returns The current session ID
*/
getSessionId() {
this.updateSessionActivity(this.sessionId);
return this.sessionId;
}
/**
* Starts a new run and returns the run ID
* @returns A new unique run ID
*/
startNewRun() {
this.currentRunId = this.generateUniqueId();
log(`SessionManager: Started new run: ${this.currentRunId}`);
this.updateSessionActivity(this.sessionId);
return this.currentRunId;
}
/**
* Gets the current run ID
* @returns The current run ID or null if no run is active
*/
getCurrentRunId() {
return this.currentRunId;
}
/**
* Ends the current run
*/
endCurrentRun() {
if (this.currentRunId) {
log(`SessionManager: Ended run: ${this.currentRunId}`);
this.currentRunId = null;
}
}
/**
* Forces session expiry (for testing or manual logout)
*/
expireSession() {
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEYS.SESSION);
log(`SessionManager: Manually expired session: ${this.sessionId}`);
}
this.sessionId = this.generateUniqueId();
this.updateSessionActivity(this.sessionId);
this.currentRunId = null;
log(`SessionManager: Created new session after manual expiry: ${this.sessionId}`);
}
/**
* Cleans up resources
*/
destroy() {
this.updateSessionActivity(this.sessionId);
this.endCurrentRun();
}
}
class ButtonManager {
constructor() {
__publicField(this, "playbackButtonsVisible", false);
__publicField(this, "rootElement", null);
__publicField(this, "playButton", null);
__publicField(this, "centerPlayButton", null);
__publicField(this, "videoManager", null);
__publicField(this, "storeUnsubscribe", null);
}
/**
* Initializes the ButtonManager with the root element and video manager
* Should be called after the player UI is created
* @param element - The root element containing the player controls
* @param videoManager - The video manager for handling interactions
*/
initialize(element, videoManager) {
if (!element) {
console.error("ButtonManager: Cannot initialize with null element");
return;
}
this.rootElement = element;
this.videoManager = videoManager;
this.storeUnsubscribe = useSaltfishStore.subscribe((state) => {
this.updatePlayPauseButton(state.currentState);
});
this.playButton = element.querySelector(".sf-controls-container__play-button");
this.centerPlayButton = element.querySelector(".sf-player__center-play-button");
if (!this.centerPlayButton) {
this.centerPlayButton = document.createElement("button");
this.centerPlayButton.className = "sf-player__center-play-button";
this.centerPlayButton.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5v14l11-7z" fill="currentColor"/>
</svg>
`;
element.appendChild(this.centerPlayButton);
this.centerPlayButton.addEventListener("click", (e) => {
var _a;
e.stopPropagation();
e.preventDefault();
const store = useSaltfishStore.getState();
if (store.currentState === "autoplayBlocked") {
if (this.videoManager) {
this.videoManager.markUserInteraction();
this.videoManager.setMuted(false);
const videoElement = this.videoManager.getVideoElement();
if (videoElement) {
videoElement.loop = false;
videoElement.currentTime = 0;
}
} else {
const videoElement = (_a = this.rootElement) == null ? void 0 : _a.querySelector(".sf-video-container__video");
if (videoElement) {
videoElement.muted = false;
videoElement.loop = false;
videoElement.currentTime = 0;
}
}
} else {
if (this.videoManager) {
this.videoManager.markUserInteraction();
}
}
if (store.currentState === "paused" || store.currentState === "waitingForInteraction" || store.currentState === "autoplayBlocked" || store.currentState === "error") {
store.play();
}
});
}
if (!this.playButton) {
console.error("ButtonManager: Failed to find play button during initialization");
}
log("ButtonManager: Center play button state: " + (this.centerPlayButton ? "found/created" : "not found"));
}
/**
* Displays or hides the DOM click button based on the given visibility state
* @param visible - Whether the button should be visible
*/
/**
* Displays or hides the playback buttons based on the given visibility state
* @param visible - Whether the buttons should be visible
*/
displayPlaybackButtons(visible) {
if (this.playbackButtonsVisible === visible) {
return;
}
this.playbackButtonsVisible = visible;
}
/**
* Updates the play/pause button state based on the video state
* @param state - The current video state ('playing' or 'paused')
*/
updatePlayPauseButton(state) {
if (this.playButton) this.playButton.style.display = "none";
if (!this.centerPlayButton) {
if (this.rootElement) {
this.centerPlayButton = this.rootElement.querySelector(".sf-player__center-play-button");
if (!this.centerPlayButton) {
this.centerPlayButton = document.createElement("button");
this.centerPlayButton.className = "sf-player__center-play-button";
this.centerPlayButton.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5v14l11-7z" fill="currentColor"/>
</svg>
`;
this.rootElement.appendChild(this.centerPlayButton);
this.centerPlayButton.addEventListener("click", (e) => {
var _a;
e.stopPropagation();
e.preventDefault();
const store = useSaltfishStore.getState();
if (store.currentState === "autoplayBlocked") {
if (this.videoManager) {
this.videoManager.markUserInteraction();
this.videoManager.setMuted(false);
const videoElement = this.videoManager.getVideoElement();
if (videoElement) {
videoElement.loop = false;
videoElement.currentTime = 0;
}
} else {
const videoElement = (_a = this.rootElement) == null ? void 0 : _a.querySelector(".sf-video-container__video");
if (videoElement) {
videoElement.muted = false;
videoElement.loop = false;
videoElement.currentTime = 0;
}
}
} else {
if (this.videoManager) {
this.videoManager.markUserInteraction();
}
}
if (store.currentState === "paused" || store.currentState === "waitingForInteraction" || store.currentState === "autoplayBlocked" || store.currentState === "error") {
store.play();
}
});
}
} else {
return;
}
}
if (state === "playing") {
this.centerPlayButton.style.display = "none";
} else {
this.centerPlayButton.style.display = "flex";
if (state === "autoplayBlocked") {
this.centerPlayButton.style.opacity = "1";
this.centerPlayButton.style.pointerEvents = "auto";
}
}
}
/**
* Cleans up any resources used by the button manager
*/
destroy() {
if (this.storeUnsubscribe) {
this.storeUnsubscribe();
this.storeUnsubscribe = null;
}
this.playbackButtonsVisible = false;
this.rootElement = null;
this.playButton = null;
this.centerPlayButton = null;
this.videoManager = null;
}
}
class TransitionManager {
constructor() {
// Track active transition listeners to avoid duplicates and ensure proper cleanup
__publicField(this, "activeTransitions", /* @__PURE__ */ new Map());
// Track current transition state
__publicField(this, "waitingForInteraction", false);
// Reference to TriggerManager for coordinating autoStart triggers
__publicField(this, "triggerManager", null);
/**
* Handles URL changes by checking active URL path transitions and autoStart triggers
*/
__publicField(this, "handleURLChange", () => {
if (this.triggerManager) {
this.triggerManager.evaluateAllTriggers();
}
const urlPathTransitions = Array.from(this.activeTransitions.entries()).filter(([_, transition]) => {
var _a;
return ((_a = transition.data) == null ? void 0 : _a.type) === "url-path";
});
if (urlPathTransitions.length === 0) return;
for (const [_, transition] of urlPathTransitions) {
if (!transition.data) continue;
const { pattern, nextStepId } = transition.data;
if (this.isURLPathMatch(pattern)) {
this.triggerTransition(nextStepId);
break;
}
}
});
window.addEventListener("popstate", this.handleURLChange);
this.monitorHistoryChanges();
}
/**
* Sets the TriggerManager reference for coordinating autoStart triggers
* @param triggerManager - The TriggerManager instance
*/
setTriggerManager(triggerManager) {
this.triggerManager = triggerManager;
}
/**
* Monitors history pushState and replaceState methods to detect SPA navigation
*/
monitorHistoryChanges() {
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = (...args) => {
originalPushState.apply(history, args);
this.handleURLChange();
};
history.replaceState = (...args) => {
originalReplaceState.apply(history, args);
this.handleURLChange();
};
}
/**
* Sets up transitions for a step
* @param step - The step to set up transitions for
* @param triggerImmediately - Whether to immediately trigger non-interaction transitions
*/
setupTransitions(step, triggerImmediately = false) {
this.cleanupTransitions();
log(`TransitionManager: Setting up transitions for step ${step.id}`);
step.transitions.forEach((transition) => {
switch (transition.type) {
case "dom-click":
this.setupDOMClickTransition(transition);
break;
case "timeout":
this.setupTimeoutTransition(transition, triggerImmediately);
break;
case "url-path":
this.setupURLPathTransition(transition);
break;
case "dom-element-visible":
this.setupDOMElementVisibleTransition(transition);
break;
default:
log(`TransitionManager: Unsupported transition type: ${transition.type}`);
}
});
}
/**
* Sets up DOM click transitions
* @param transition - The transition configuration
*/
setupDOMClickTransition(transition) {
if (!transition.target) {
return;
}
const selector = transition.target;
const nextStepId = transition.nextStep;
const transitionId = `dom-click-${selector}-${Date.now()}`;
const handlers = /* @__PURE__ */ new Map();
let mutationObserver = null;
const addClickHandlersToElements = (elements) => {
if (elements.length === 0) {
return;
}
log(`TransitionManager: Found ${elements.length} elements matching selector '${selector}'`);
elements.forEach((element) => {
if (handlers.has(element)) {
return;
}
const handler = (_event) => {
this.triggerTransition(nextStepId);
};
handlers.set(element, handler);
element.addEventListener("click", handler);
});
};
const initialElements = document.querySelectorAll(selector);
addClickHandlersToElements(initialElements);
mutationObserver = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const elementNode = node;
if (elementNode.matches(selector)) {
addClickHandlersToElements([elementNode]);
}
const matchingDescendants = elementNode.querySelectorAll(selector);
if (matchingDescendants.length > 0) {
log(`TransitionManager: Found ${matchingDescendants.length} descendants matching '${selector}'`);
addClickHandlersToElements(matchingDescendants);
}
}
});
}
}
});
mutationObserver.observe(document.body, { childList: true, subtree: true });
this.activeTransitions.set(transitionId, {
handlers,
cleanup: () => {
handlers.forEach((handler, element) => {
element.removeEventListener("click", handler);
});
handlers.clear();
if (mutationObserver) {
mutationObserver.disconnect();
mutationObserver = null;
}
},
data: {
type: "dom-click",
pattern: selector,
nextStepId
}
});
}
/**
* Sets up timeout transitions
* @param transition - The transition configuration
* @param triggerImmediately - Whether to trigger immediately
*/
setupTimeoutTransition(transition, triggerImmediately) {
const nextStepId = transition.nextStep;
const timeout = transition.timeout || 0;
const transitionId = `timeout-${timeout}-${Date.now()}`;
let timeoutId = null;
if (triggerImmediately) {
this.triggerTransition(nextStepId);
} else if (timeout > 0) {
timeoutId = window.setTimeout(() => {
this.triggerTransition(nextStepId);
}, timeout);
}
this.activeTransitions.set(transitionId, {
handlers: /* @__PURE__ */ new Map(),
// No DOM handlers for timeout transitions
cleanup: () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
},
data: {
type: "timeout",
pattern: "",
nextStepId
}
});
}
/**
* Sets up URL path transitions
* @param transition - The transition configuration
*/
setupURLPathTransition(transition) {
if (!transition.target) {
return;
}
const pathPattern = transition.target;
const nextStepId = transition.nextStep;
const initialMatch = this.isURLPathMatch(pathPattern);
if (initialMatch) {
this.triggerTransition(nextStepId);
const maxRetries = 5;
let retries = 0;
const retryTransition = () => {
if (retries >= maxRetries) {
return;
}
retries++;
const currentStore = useSaltfishStore.getState();
if (currentStore.currentState === "waitingForInteraction" || currentStore.currentState === "playing") {
log(`TransitionManager: State now compatible (${currentStore.currentState}), triggering transition to '${nextStepId}'`);
this.triggerTransition(nextStepId);
} else if (retries < maxRetries) {
log(`TransitionManager: State still incompatible (${currentStore.currentState}), will retry`);
setTimeout(retryTransition, 500);
}
};
setTimeout(retryTransition, 500);
}
const transitionId = `url-path-${Date.now()}`;
const intervalId = window.setInterval(() => {
const match = this.isURLPathMatch(pathPattern);
if (match) {
clearInterval(intervalId);
this.triggerTransition(nextStepId);
}
}, 5e3);
this.activeTransitions.set(transitionId, {
handlers: /* @__PURE__ */ new Map(),
// No DOM handlers for URL path transitions
cleanup: () => {
clearInterval(intervalId);
},
data: {
type: "url-path",
pattern: pathPattern,
nextStepId
}
});
}
/**
* Checks if the current URL path matches a pattern
*/
isURLPathMatch(pattern) {
if (!pattern) {
return false;
}
const currentUrl = window.location.href.split("#")[0];
const currentPath = window.location.pathname;
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regexPattern = escapedPattern.replace(/\\\*/g, ".*");
const regex = new RegExp(regexPattern);
const fullUrlMatch = regex.test(currentUrl);
const pathMatch = regex.test(currentPath);
return fullUrlMatch || pathMatch;
}
/**
* Triggers a transition to a new step
* @param nextStepId - The ID of the step to transition to
*/
triggerTransition(nextStepId) {
var _a;
const store = useSaltfishStore.getState();
const currentState = store.currentState;
store.currentStepId;
const isMinimized = store.isMinimized;
if (currentState !== "playing" && currentState !== "waitingForInteraction") {
return;
}
if (isMinimized) {
return;
}
this.cleanupTransitions();
if (store.goToStep) {
store.goToStep(nextStepId);
} else {
(_a = store.goToStep) == null ? void 0 : _a.call(store, nextStepId);
}
}
/**
* Cleans up all active transitions
*/
cleanupTransitions() {
this.activeTransitions.forEach((transition) => {
transition.cleanup();
});
this.activeTransitions.clear();
this.waitingForInteraction = false;
}
/**
* Sets the waiting for interaction state
* @param isWaiting - Whether the player is waiting for interaction
*/
setWaitingForInteraction(isWaiting) {
this.waitingForInteraction = isWaiting;
}
/**
* Checks if the player is waiting for interaction
* @returns Whether the player is waiting for interaction
*/
isWaitingForInteraction() {
return this.waitingForInteraction;
}
/**
* Destroys the transition manager and cleans up resources
*/
destroy() {
this.cleanupTransitions();
window.removeEventListener("popstate", this.handleURLChange);
}
/**
* Sets up DOM element visible transitions
* @param transition - The transition configuration
*/
setupDOMElementVisibleTransition(transition) {
if (!transition.target) {
return;
}
const selector = transition.target;
const nextStepId = transition.nextStep;
const transitionId = `dom-visible-${selector}-${Date.now()}`;
let intersectionObserver = null;
let mutationObserver = null;
let targetElement = null;
let periodicCheck = null;
const cleanup = () => {
if (intersectionObserver) {
intersectionObserver.disconnect();
intersectionObserver = null;
}
if (mutationObserver) {
mutationObserver.disconnect();
mutationObserver = null;
}
if (periodicCheck) {
clearInterval(periodicCheck);
periodicCheck = null;
}
targetElement = null;
};
const setupIntersectionObserver = (element) => {
if (intersectionObserver) return;
targetElement = element;
intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
var _a;
log(`TransitionManager: IntersectionObserver callback for '${selector}': isIntersecting=${entry.isIntersecting}, ratio=${entry.intersectionRatio.toFixed(2)}, target=${entry.target.outerHTML.substring(0, 100)}...`);
if (entry.isIntersecting && entry.target === targetElement) {
const styles = window.getComputedStyle(entry.target);
const isReallyVisible = styles.opacity !== "0" && styles.display !== "none" && styles.visibility === "visible" && styles.pointerEvents !== "none";
if (!isReallyVisible) {
return;
}
this.triggerTransition(nextStepId);
(_a = this.activeTransitions.get(transitionId)) == null ? void 0 : _a.cleanup();
this.activeTransitions.delete(transitionId);
} else if (entry.target === targetElement) {
const rect = entry.target.getBoundingClientRect();
log(`TransitionManager: Element '${selector}' reported as NOT intersecting. BoundingClientRect: top=${rect.top.toFixed(0)}, left=${rect.left.toFixed(0)}, bottom=${rect.bottom.toFixed(0)}, right=${rect.right.toFixed(0)}, width=${rect.width.toFixed(0)}, height=${rect.height.toFixed(0)}`);
}
});
},
{
root: null,
// Explicitly use viewport as root
rootMargin: "0px",
// Ensure no margins are affecting detection
threshold: 0
// Trigger if even 1px is visible
}
);
intersectionObserver.observe(element);
if (mutationObserver) {
mutationObserver.disconnect();
mutationObserver = null;
}
};
const initialElement = document.querySelector(selector);
if (initialElement) {
setupIntersectionObserver(initialElement);
} else {
mutationObserver = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const elementNode = node;
if (elementNode.matches(selector)) {
setupIntersectionObserver(elementNode);
return;
}
const matchingDescendant = elementNode.querySelector(selector);
if (matchingDescendant) {
setupIntersectionObserver(matchingDescendant);
return;
}
}
});
}
if (!mutationObserver) break;
}
});
mutationObserver.observe(document.body, { childList: true, subtree: true });
periodicCheck = window.setInterval(() => {
if (!mutationObserver) {
if (periodicCheck) clearInterval(periodicCheck);
return;
}
const element = document.querySelector(selector);
if (element && element.offsetWidth > 0 && element.offsetHeight > 0) {
if (periodicCheck) clearInterval(periodicCheck);
periodicCheck = null;
setupIntersectionObserver(element);
}
}, 1e3);
}
this.activeTransitions.set(transitionId, {
handlers: /* @__PURE__ */ new Map(),
// No direct event handlers needed here
cleanup,
data: {
type: "dom-element-visible",
pattern: selector,
nextStepId
}
});
}
}
class TriggerManager {
constructor() {
__publicField(this, "autoStartPlaylists", []);
__publicField(this, "triggeredPlaylists", /* @__PURE__ */ new Set());
// Track which playlists have been triggered this session
__publicField(this, "isMonitoring", false);
}
/**
* Registers autoStart playlists and their trigger configurations
* @param playlists - List of all playlists from backend
*/
registerTriggers(playlists) {
this.autoStartPlaylists = playlists.filter(
(playlist) => playlist.autoStart && playlist.triggers && playlist.isLive
);
log(`TriggerManager: Registered ${this.autoStartPlaylists.length} autoStart playlists with triggers`);
this.autoStartPlaylists.forEach((playlist) => {
var _a, _b;
log(`TriggerManager: Registered trigger for playlist ${playlist.id} - URL: ${(_a = playlist.triggers) == null ? void 0 : _a.url}, Once: ${(_b = playlist.triggers) == null ? void 0 : _b.once}`);
});
}
/**
* Starts monitoring for trigger conditions
*/
startMonitoring() {
if (this.isMonitoring) {
return;
}
this.isMonitoring = true;
this.evaluateAllTriggers();
}
/**
* Stops monitoring for trigger conditions
*/
stopMonitoring() {
this.isMonitoring = false;
}
/**
* Evaluates all registered triggers against current conditions
* Called by TransitionManager when URL changes occur
*/
evaluateAllTriggers() {
if (!this.isMonitoring || this.autoStartPlaylists.length === 0) {
return;
}
for (const playlist of this.autoStartPlaylists) {
this.evaluatePlaylistTrigger(playlist);
}
}
/**
* Evaluates triggers for a specific playlist
* @param playlist - The playlist to evaluate triggers for
*/
evaluatePlaylistTrigger(playlist) {
var _a, _b, _c;
if (!playlist.triggers) {
return;
}
const { triggers } = playlist;
const playlistId = playlist.id;
if (this.triggeredPlaylists.has(playlistId)) {
return;
}
const store = useSaltfishStore.getState();
if (!store.user) {
return;
}
const conditions = [];
const onceCondition = this.evaluateOnceCondition(triggers.once, playlistId, (_a = store.userData) == null ? void 0 : _a.watchedPlaylists);
conditions.push(onceCondition);
const urlCondition = this.evaluateURLCondition(triggers.url);
conditions.push(urlCondition);
log(`TriggerManager: URL condition for playlist ${playlistId}: ${urlCondition} (pattern: ${triggers.url})`);
const playlistSeenCondition = this.evaluatePlaylistSeenCondition(triggers.playlistSeen, (_b = store.userData) == null ? void 0 : _b.watchedPlaylists);
conditions.push(playlistSeenCondition);
log(`TriggerManager: PlaylistSeen condition for playlist ${playlistId}: ${playlistSeenCondition} (required: ${JSON.stringify(triggers.playlistSeen)})`);
const playlistNotSeenCondition = this.evaluatePlaylistNotSeenCondition(triggers.playlistNotSeen, (_c = store.userData) == null ? void 0 : _c.watchedPlaylists);
conditions.push(playlistNotSeenCondition);
log(`TriggerManager: PlaylistNotSeen condition for playlist ${playlistId}: ${playlistNotSeenCondition} (forbidden: ${JSON.stringify(triggers.playlistNotSeen)})`);
const shouldTrigger = this.applyOperators(conditions, triggers.operators);
log(`TriggerManager: Final evaluation for playlist ${playlistId}: ${shouldTrigger} (operator: ${triggers.operators.join(", ")})`);
if (shouldTrigger) {
this.triggerPlaylist(playlistId);
}
}
/**
* Evaluates the 'once' condition for a playlist
* @param once - Whether playlist should only trigger once per user
* @param playlistId - The playlist ID to check
* @param watchedPlaylists - User's watched playlists data
*/
evaluateOnceCondition(once, playlistId, watchedPlaylists) {
if (!once) {
return true;
}
const hasWatched = watchedPlaylists && watchedPlaylists[playlistId];
return !hasWatched;
}
/**
* Evaluates the URL condition for a playlist
* @param pattern - URL pattern to match against (null = no URL condition)
*/
evaluateURLCondition(pattern) {
if (!pattern) {
return true;
}
const currentUrl = window.location.href.split("#")[0];
const currentPath = window.location.pathname;
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regexPattern = escapedPattern.replace(/\\\*/g, ".*");
const regex = new RegExp(regexPattern);
const fullUrlMatch = regex.test(currentUrl);
const pathMatch = regex.test(currentPath);
return fullUrlMatch || pathMatch;
}
/**
* Evaluates the playlistSeen condition
* User must have seen ALL specified playlists
* @param requiredPlaylists - Array of playlist IDs that user must have seen
* @param watchedPlaylists - User's watched playlists data
*/
evaluatePlaylistSeenCondition(requiredPlaylists, watchedPlaylists) {
if (!requiredPlaylists || requiredPlaylists.length === 0) {
return true;
}
if (!watchedPlaylists) {
return false;
}
for (const playlistId of requiredPlaylists) {
const playlistData = watchedPlaylists[playlistId];
if (!playlistData || playlistData.status !== "completed" && playlistData.status !== "in_progress") {
return false;
}
}
return true;
}
/**
* Evaluates the playlistNotSeen condition
* User must NOT have seen ANY of the specified playlists
* @param forbiddenPlaylists - Array of playlist IDs that user must not have seen
* @param watchedPlaylists - User's watched playlists data
*/
evaluatePlaylistNotSeenCondition(forbiddenPlaylists, watchedPlaylists) {
if (!forbiddenPlaylists || forbiddenPlaylists.length === 0) {
return true;
}
if (!watchedPlaylists) {
return true;
}
for (const playlistId of forbiddenPlaylists) {
const playlistData = watchedPlaylists[playlistId];
if (playlistData && (playlistData.status === "completed" || playlistData.status === "in_progress")) {
return false;
}
}
return true;
}
/**
* Applies logical operators to combine multiple conditions
* @param conditions - Array of boolean conditions to combine
* @param operators - Array of operators ("AND" or "OR")
*/
applyOperators(conditions, operators) {
if (conditions.length === 0) {
return false;
}
if (conditions.length === 1) {
return conditions[0];
}
if (!operators || operators.length === 0) {
return conditions.every((condition) => condition);
}
if (operators.includes("OR")) {
return conditions.some((condition) => condition);
}
return conditions.every((condition) => condition);
}
/**
* Triggers a playlist to start
* @param playlistId - ID of the playlist to trigger
*/
async triggerPlaylist(playlistId) {
this.triggeredPlaylists.add(playlistId);
try {
const saltfishPlayer = window._saltfishPlayer;
if (saltfishPlayer && typeof saltfishPlayer.startPlaylist === "function") {
await saltfishPlayer.startPlaylist(playlistId);
log(`TriggerManager: Successfully triggered playlist ${playlistId}`);
} else {
log(`TriggerManager: Error - SaltfishPlayer instance not found or startPlaylist method not available`);
}
} catch (error2) {
this.triggeredPlaylists.delete(playlistId);
}
}
/**
* Resets the triggered playlists tracking
* Useful for testing or when user context changes
*/
resetTriggeredPlaylists() {
this.triggeredPlaylists.clear();
}
/**
* Gets list of playlists that have been triggered this session
*/
getTriggeredPlaylists() {
return Array.from(this.triggeredPlaylists);
}
/**
* Cleanup method to be called on destroy
*/
destroy() {
this.stopMonitoring();
this.autoStartPlaylists = [];
this.triggeredPlaylists.clear();
}
}
class EventManager {
constructor() {
__publicField(this, "listeners", /* @__PURE__ */ new Map());
}
/**
* Subscribes to an event
* @param eventName - Name of the event to subscribe to
* @param handler - Function to call when the event is triggered
*/
on(eventName, handler) {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, /* @__PURE__ */ new Set());
}
this.listeners.get(eventName).add(handler);
}
/**
* Unsubscribes from an event
* @param eventName - Name of the event to unsubscribe from
* @param handler - Handler function to remove
* @returns true if the handler was removed, false if it wasn't found
*/
off(eventName, handler) {
const handlers = this.listeners.get(eventName);
if (!handlers) {
return false;
}
return handlers.delete(handler);
}
/**
* Triggers an event, calling all subscribed handlers
* @param eventName - Name of the event to trigger
* @param payload - Data to pass to the event handlers
*/
trigger(eventName, payload) {
const handlers = this.listeners.get(eventName);
if (!handlers || handlers.size === 0) {
return;
}
if (!("timestamp" in payload)) {
payload.timestamp = Date.now();
}
handlers.forEach((handler) => {
try {
handler(payload);
} catch (error2) {
console.error(`Error in ${eventName} event handler:`, error2);
}
});
}
/**
* Removes all event listeners
*/
removeAllListeners() {
this.listeners.clear();
}
/**
* Gets the count of listeners for a specific event
* @param eventName - Name of the event
* @returns Number of listeners for the event
*/
getListenerCount(eventName) {
const handlers = this.listeners.get(eventName);
return handlers ? handlers.size : 0;
}
}
class PlaylistManager {
/**
* Creates a new PlaylistManager
* @param eventManager - Optional event manager to subscribe to events
*/
constructor(eventManager) {
__publicField(this, "eventManager", null);
__publicField(this, "isUpdatingWatchedPlaylists", false);
if (eventManager) {
this.setEventManager(eventManager);
}
}
/**
* Sets the event manager and subscribes to relevant events
* @param eventManager - Event manager instance
*/
setEventManager(eventManager) {
this.eventManager = eventManager;
this.subscribeToEvents();
}
/**
* Subscribe to relevant player events for playlist tracking
*/
subscribeToEvents() {
if (!this.eventManager) return;
this.eventManager.on("playlistStarted", (event) => {
log(`PlaylistManager: Playlist started event received - ${event.playlist.id}`);
this.updateWatchedPlaylistStatus(event.playlist.id, "in_progress");
});
this.eventManager.on("playlistEnded", (event) => {
log(`PlaylistManager: Playlist ended event received - ${event.playlist.id}`);
this.updateWatchedPlaylistStatus(event.playlist.id, "completed");
});
this.eventManager.on("playlistDismissed", (event) => {
log(`PlaylistManager: Playlist dismissed event received - ${event.playlist.id}`);
this.updateWatchedPlaylistStatus(event.playlist.id, "dismissed");
});
this.eventManager.on("stepStarted", (event) => {
log(`PlaylistManager: Step started event received - ${event.step.id}`);
this.updateWatchedPlaylistStatus(event.playlist.id, "in_progress", event.step.id);
});
}
/**
* Helper to get the store state when needed
*/
getStore() {
try {
return useSaltfishStore.getState();
} catch (error2) {
console.error("Failed to access store:", error2);
return null;
}
}
/**
* Updates the watched playlist status locally and in the backend
* @param playlistId - ID of the playlist
* @param status - New status ('in_progress' or 'completed')
* @param currentStepId - Optional current step ID
*/
async updateWatchedPlaylistStatus(playlistId, status, currentStepId) {
var _a, _b, _c;
if (this.isUpdatingWatchedPlaylists) {
return;
}
this.isUpdatingWatchedPlaylists = true;
try {
const store = this.getStore();
if (!store) {
return;
}
const currentUserData = store.userData || {};
const currentWatchedPlaylists = currentUserData.watchedPlaylists || {};
const updatedPlaylistData = {
status,
currentStepId: currentStepId || store.currentStepId || null,
lastProgressAt: Date.now()
};
const updatedWatchedPlaylists = {
...currentWatchedPlaylists,
[playlistId]: updatedPlaylistData
};
store.setUserData({
...currentUserData,
watchedPlaylists: updatedWatchedPlaylists
});
log(`PlaylistManager: Updated local watched playlists for ${playlistId} with status ${status}`);
if (!((_a = store == null ? void 0 : store.config) == null ? void 0 : _a.token) || !((_b = store == null ? void 0 : store.user) == null ? void 0 : _b.id) || ((_c = store == null ? void 0 : store.user) == null ? void 0 : _c.__isAnonymous)) {
this.updateAnonymousUserWatchedPlaylists(playlistId, status, currentStepId || store.currentStepId || null);
return;
}
const apiUrl = `https://player.saltfish.ai/clients/${store.config.token}/users/${store.user.id}/playlists/${playlistId}`;
try {
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
status,
currentStepId: currentStepId || store.currentStepId || null
})
});
if (!response.ok) {
throw new Error(`Failed to update watched playlist status: ${response.statusText}`);
}
} catch (error2) {
console.error("Error updating watched playlist status:", error2);
}
} finally {
this.isUpdatingWatchedPlaylists = false;
}
}
/**
* Updates anonymous user watched playlist status in localStorage
* @param playlistId - ID of the playlist
* @param status - New status ('in_progress', 'completed', or 'dismissed')
* @param currentStepId - Optional current step ID
*/
updateAnonymousUserWatchedPlaylists(playlistId, status, currentStepId) {
if (typeof window === "undefined") {
return;
}
try {
const existingDataStr = localStorage.getItem(STORAGE_KEYS.ANONYMOUS_USER);
let anonymousUserData = {
userId: "anonymous",
userData: {},
watchedPlaylists: {},
timestamp: Date.now()
};
if (existingDataStr) {
try {
anonymousUserData = JSON.parse(existingDataStr);
} catch (parseError) {
log("[PlaylistManager.updateAnonymousUserWatchedPlaylists] Error parsing existing anonymous data, using defaults:", parseError);
}
}
if (!anonymousUserData.watchedPlaylists) {
anonymousUserData.watchedPlaylists = {};
}
anonymousUserData.watchedPlaylists[playlistId] = {
status,
currentStepId: currentStepId || null,
lastProgressAt: Date.now()
};
anonymousUserData.timestamp = Date.now();
localStorage.setItem(STORAGE_KEYS.ANONYMOUS_USER, JSON.stringify(anonymousUserData));
log(`PlaylistManager: Updated anonymous user localStorage for playlist ${playlistId} with status ${status}`);
} catch (error2) {
console.error("PlaylistManager: Error updating anonymous user watched playlists:", error2);
}
}
/**
* Loads a playlist manifest and sets up the store
* @param playlistId - Path or identifier for the playlist manifest
* @param options - Playlist configuration options
*/
async load(playlistId, options) {
try {
let manifest;
const manifestIdentifier = playlistId.includes("/") ? playlistId.split("/").pop() || playlistId : playlistId;
if (typeof window !== "undefined" && window.demoManifest) {
log("Using custom demo manifest from window:", manifestIdentifier);
manifest = window.demoManifest;
} else {
try {
log("[PlaylistManager] Attempting to fetch manifest from path:", playlistId);
const response = await fetch(playlistId);
log("[PlaylistManager] Fetch response status:", { status: response.status, statusText: response.statusText });
if (!response.ok) {
log("[PlaylistManager] Fetch response not OK:", response);
throw new Error(`Failed to fetch manifest: ${response.statusText} from ${playlistId}`);
}
log("[PlaylistManager] Fetch response OK, attempting to parse JSON...");
manifest = await response.json();
log("[PlaylistManager] Successfully parsed manifest JSON.");
} catch (fetchError) {
error("[PlaylistManager] Failed to fetch or parse manifest:", fetchError);
error("[PlaylistManager] Manifest path:", playlistId);
error("[PlaylistManager] Manifest identifier:", manifestIdentifier);
throw new Error(`Unable to load valid playlist manifest from "${playlistId}". Please check the path and ensure the manifest file exists and is properly formatted.`);
}
}
const startStepId = this.determineStartStep(manifest, options);
const firstStep = manifest.steps.find((step) => step.id === startStepId);
const store = useSaltfishStore.getState();
store.setManifest(manifest, startStepId);
if (firstStep) {
store.stateMachine.send({
type: "MANIFEST_LOADED",
step: firstStep
});
}
} catch (error2) {
const store = useSaltfishStore.getState();
const errorObj = error2 instanceof Error ? error2 : new Error("Unknown error loading manifest");
store.stateMachine.send({
type: "ERROR",
error: errorObj
});
store.setError(errorObj);
}
}
/**
* Determines which step to start from based on persistence settings and saved progress
* @param manifest - The loaded playlist manifest
* @param options - Playlist configuration options
* @returns The step ID to start from
*/
determineStartStep(manifest, options) {
var _a;
const store = useSaltfishStore.getState();
const { progress } = store;
const isPersistenceEnabled = options.persistence ?? true;
const manifestIdForProgress = manifest.id;
let startStepId = manifest.startStep;
if (isPersistenceEnabled && progress && ((_a = progress[manifestIdForProgress]) == null ? void 0 : _a.lastStepId)) {
const lastStepId = progress[manifestIdForProgress].lastStepId;
const savedStep = manifest.steps.find((step) => step.id === lastStepId);
if (savedStep) {
startStepId = lastStepId;
}
}
return startStepId;
}
/**
* Cleans up resources used by the playlist manager
*/
destroy() {
if (this.eventManager) {
this.eventManager = null;
}
}
}
class PlayerView {
constructor() {
__publicField(this, "playerElement", null);
}
/**
* Creates the main player DOM structure
* @param shadowRoot - The shadow DOM root element to append to
* @returns The created player element
*/
create(shadowRoot) {
if (this.playerElement) {
console.warn("PlayerView: Player element already exists, returning existing element");
return this.playerElement;
}
this.playerElement = document.createElement("div");
this.playerElement.className = CSS_CLASSES.PLAYER;
shadowRoot.appendChild(this.playerElement);
return this.playerElement;
}
/**
* Creates the controls container structure
* @param parentElement - The parent element to append the controls container to
* @returns The created controls container element
*/
createControlsContainer(parentElement) {
const controlsContainer = document.createElement("div");
controlsContainer.className = CSS_CLASSES.CONTROLS_CONTAINER;
parentElement.appendChild(controlsContainer);
return controlsContainer;
}
/**
* Creates and adds the saltfish logo to the player
* @param parentElement - The parent element to append the logo to
*/
createSaltfishLogo(parentElement) {
var _a;
const store = useSaltfishStore.getState();
if (((_a = store.config) == null ? void 0 : _a.showLogo) === false) {
return;
}
const logoContainer = document.createElement("div");
logoContainer.className = CSS_CLASSES.LOGO;
logoContainer.innerHTML = `
<svg width="41" height="15" viewBox="0 0 41 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.42034 7.49991C8.42034 9.67502 6.65707 11.4383 4.48196 11.4383C2.30685 11.4383 4.00664 9.67502 4.00664 7.49991C4.00664 5.3248 2.30685 3.56152 4.48196 3.56152C6.65707 3.56152 8.42034 5.3248 8.42034 7.49991Z" fill="white"/>
<path d="M3.53097 7.43198C3.53097 8.96956 2.29707 10.216 0.774969 10.216C-0.747128 10.216 0.442349 8.96956 0.442349 7.43198C0.442349 5.8944 -0.747128 4.64795 0.774969 4.64795C2.29707 4.64795 3.53097 5.8944 3.53097 7.43198Z" fill="white"/>
<path d="M15.0603 10.628C13.8603 10.628 12.9803 9.892 12.9803 8.372L13.6043 8.292C13.6043 9.492 14.1803 10.044 15.0603 10.044C15.8603 10.044 16.3243 9.612 16.3243 8.972C16.3243 8.252 15.8363 7.988 15.0603 7.788C13.7003 7.436 13.2523 6.996 13.2523 6.196C13.2523 5.396 13.9403 4.772 14.9803 4.772C16.0203 4.772 16.6923 5.396 16.6923 6.436L16.0683 6.516C16.0683 5.796 15.6203 5.356 14.9803 5.356C14.3403 5.356 13.8763 5.636 13.8763 6.196C13.8763 6.756 14.2843 7.004 15.0603 7.204C16.3723 7.548 16.9483 8.012 16.9483 8.972C16.9483 9.932 16.2603 10.628 15.0603 10.628ZM19.182 10.628C18.542 10.628 17.83 10.228 17.83 9.428C17.83 8.548 18.462 8.212 19.494 8.076C19.966 8.012 20.638 7.956 20.414 7.38C20.27 7.004 19.734 6.956 19.494 6.956C18.854 6.956 18.534 7.236 18.534 7.716L17.91 7.636C17.91 6.756 18.774 6.372 19.494 6.372C20.214 6.372 21.022 6.692 21.022 7.652V10.5H20.438V9.7C20.262 10.22 19.798 10.628 19.182 10.628ZM18.454 9.46C18.454 9.78 18.782 10.044 19.182 10.044C19.822 10.044 20.438 9.596 20.438 8.476V8.26C20.286 8.476 19.91 8.596 19.374 8.66C18.742 8.748 18.454 9.06 18.454 9.46ZM22.3044 10.5V4.82H22.9284V10.5H22.3044ZM25.6789 10.5C24.9909 10.5 24.6469 10.204 24.6469 9.508V7.084H23.9669V6.5H24.6469V5.292L25.1909 5.212V6.5H26.2309V7.084H25.1909V9.34C25.1909 9.836 25.3829 9.916 25.7749 9.916H26.2309V10.5H25.6789ZM27.7094 6.052C27.7094 5.116 28.0934 4.82 28.7814 4.82H29.3334V5.404H28.8774C28.4454 5.404 28.2934 5.484 28.2934 6.22V6.5H29.3334V7.084H28.2934V10.5H27.7094V7.084H27.0294V6.5H27.7094V6.052ZM30.2805 10.5V6.5H30.9045V10.5H30.2805ZM30.2165 5.86V5.108H30.9685V5.86H30.2165ZM33.7678 10.628C32.8078 10.628 32.0078 10.06 32.0078 9.02L32.5918 8.94C32.5918 9.66 33.2078 10.044 33.7678 10.044C34.3278 10.044 34.7678 9.86 34.7678 9.38C34.7678 8.9 34.4558 8.772 33.9998 8.708L33.2958 8.612C32.6638 8.524 32.2318 8.092 32.2318 7.532C32.2318 6.812 32.8478 6.372 33.7278 6.372C34.6078 6.372 35.1678 6.892 35.1678 7.612L34.5838 7.692C34.5838 7.212 34.2878 6.956 33.7278 6.956C33.2478 6.956 32.8558 7.132 32.8558 7.532C32.8558 7.852 33.0718 7.996 33.5438 8.068L34.1518 8.156C34.8558 8.26 35.3918 8.66 35.3918 9.38C35.3918 10.1 34.7278 10.628 33.7678 10.628ZM38.9252 7.868C38.9252 7.148 38.5812 6.956 38.1012 6.956C37.6212 6.956 36.9812 7.276 36.9812 8.636V10.5H36.3572V4.9H36.9812V7.364C37.1092 6.812 37.5652 6.372 38.3012 6.372C39.1812 6.372 39.5492 6.988 39.5492 7.868V10.5H38.9252V7.868Z" fill="white"/>
</svg>
`;
parentElement.appendChild(logoContainer);
logoContainer.addEventListener("click", (event) => {
var _a2;
event.stopPropagation();
const currentStore = useSaltfishStore.getState();
const token = (_a2 = currentStore.config) == null ? void 0 : _a2.token;
if (token) {
window.open(`https://www.saltfish.ai/demos?clientId=${token}`, "_blank");
} else {
console.warn("PlayerView: No token available, falling back to saltfish.ai homepage");
window.open("https://www.saltfish.ai/", "_blank");
}
});
}
/**
* Gets the player element
*/
getPlayerElement() {
return this.playerElement;
}
/**
* Destroys the player view and cleans up resources
*/
destroy() {
if (this.playerElement && this.playerElement.parentElement) {
this.playerElement.parentElement.removeChild(this.playerElement);
}
this.playerElement = null;
}
}
class DragManager {
constructor() {
__publicField(this, "playerElement", null);
__publicField(this, "playerRoot", null);
// Drag-related state
__publicField(this, "isDragging", false);
__publicField(this, "dragOffset", { x: 0, y: 0 });
__publicField(this, "justFinishedDragging", false);
__publicField(this, "dragStartPosition", { x: 0, y: 0 });
__publicField(this, "hasMoved", false);
__publicField(this, "resizeListener", null);
// Callback for when drag state changes (used to update button positions)
__publicField(this, "onDragStateChange", null);
}
/**
* Initializes the drag manager with the player elements
* @param playerElement - The draggable player element
* @param playerRoot - The root element that gets positioned
* @param onDragStateChange - Callback for when drag state changes
*/
initialize(playerElement, playerRoot, onDragStateChange) {
this.playerElement = playerElement;
this.playerRoot = playerRoot;
this.onDragStateChange = onDragStateChange || null;
this.setupDragHandlers();
}
/**
* Sets up drag handlers for the player
*/
setupDragHandlers() {
if (!this.playerElement) return;
const onMouseDown = (event) => {
var _a, _b;
if (!this.playerElement) return;
if (event.target.tagName === "BUTTON") {
return;
}
this.isDragging = true;
this.justFinishedDragging = false;
this.hasMoved = false;
this.dragStartPosition = {
x: event.clientX,
y: event.clientY
};
const store = useSaltfishStore.getState();
const rect = this.playerElement.getBoundingClientRect();
this.dragOffset = {
x: event.clientX - (((_a = store.position) == null ? void 0 : _a.x) || rect.left),
y: event.clientY - (((_b = store.position) == null ? void 0 : _b.y) || rect.top)
};
event.preventDefault();
};
const onMouseMove = (event) => {
var _a;
if (!this.isDragging) return;
const distanceX = Math.abs(event.clientX - this.dragStartPosition.x);
const distanceY = Math.abs(event.clientY - this.dragStartPosition.y);
if (distanceX > TIMING.DRAG_THRESHOLD_PX || distanceY > TIMING.DRAG_THRESHOLD_PX) {
this.hasMoved = true;
}
const store = useSaltfishStore.getState();
let newX = event.clientX - this.dragOffset.x;
let newY = event.clientY - this.dragOffset.y;
let positionToUse = (_a = store.playlistOptions) == null ? void 0 : _a.position;
if (store.currentStepId && store.manifest) {
const currentStep = store.manifest.steps.find((step) => step.id === store.currentStepId);
if (currentStep == null ? void 0 : currentStep.position) {
positionToUse = currentStep.position;
}
}
const constrainedPosition = PositionCalculator.calculateDragPosition({
x: newX,
y: newY,
position: positionToUse
});
this.updatePlayerPosition(constrainedPosition, positionToUse || "bottom-right");
event.preventDefault();
};
const onMouseUp = (event) => {
var _a;
if (this.isDragging) {
const store = useSaltfishStore.getState();
let newX = event.clientX - this.dragOffset.x;
let newY = event.clientY - this.dragOffset.y;
let positionToUse = (_a = store.playlistOptions) == null ? void 0 : _a.position;
if (store.currentStepId && store.manifest) {
const currentStep = store.manifest.steps.find((step) => step.id === store.currentStepId);
if (currentStep == null ? void 0 : currentStep.position) {
positionToUse = currentStep.position;
}
}
const constrainedPosition = PositionCalculator.calculateDragPosition({
x: newX,
y: newY,
position: positionToUse
});
store.setPosition(constrainedPosition.x, constrainedPosition.y);
if (this.onDragStateChange) {
this.onDragStateChange();
}
if (this.hasMoved) {
this.justFinishedDragging = true;
setTimeout(() => {
this.justFinishedDragging = false;
}, TIMING.DRAG_RESET_DELAY);
}
}
this.isDragging = false;
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
this.playerElement.addEventListener("mousedown", onMouseDown);
this.playerElement.addEventListener("mousedown", () => {
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
this.resizeListener = () => {
setTimeout(() => {
if (this.onDragStateChange) {
this.onDragStateChange();
}
}, 100);
};
window.addEventListener("resize", this.resizeListener);
}
/**
* Updates the player position during drag operations
* @param position - The new position coordinates
* @param positionAlignment - The position alignment setting
*/
updatePlayerPosition(position, positionAlignment) {
if (!this.playerRoot) return;
const transforms = PositionCalculator.getTransforms(positionAlignment);
this.playerRoot.style.left = `${position.x}px`;
this.playerRoot.style.top = `${position.y}px`;
this.playerRoot.style.transform = `translate(${transforms.transformX}, ${transforms.transformY})`;
}
/**
* Checks if the UI is currently being dragged
*/
isCurrentlyDragging() {
return this.isDragging;
}
/**
* Checks if dragging just finished
*/
hasJustFinishedDragging() {
return this.justFinishedDragging;
}
/**
* Resets drag state
*/
resetDragState() {
this.isDragging = false;
this.justFinishedDragging = false;
this.hasMoved = false;
}
/**
* Destroys the drag manager and cleans up resources
*/
destroy() {
if (this.resizeListener) {
window.removeEventListener("resize", this.resizeListener);
this.resizeListener = null;
}
this.resetDragState();
this.playerElement = null;
this.playerRoot = null;
this.onDragStateChange = null;
}
}
class MinimizeButton {
constructor(playerElement) {
__publicField(this, "button");
__publicField(this, "playerElement");
this.playerElement = playerElement;
this.button = document.createElement("button");
this.button.className = "sf-player__minimize-button";
this.button.innerHTML = `
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
this.button.addEventListener("click", this.handleClick.bind(this));
this.playerElement.appendChild(this.button);
this.updateVisibility(useSaltfishStore.getState().isMinimized);
}
handleClick() {
const store = useSaltfishStore.getState();
const isMinimized = !store.isMinimized;
if (isMinimized) {
store.minimize();
if (store.currentState === "playing") {
store.pause();
}
} else {
store.maximize();
}
if (isMinimized) {
this.minimize();
} else {
this.maximize();
}
}
minimize() {
this.button.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
}
maximize() {
this.button.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
}
updateVisibility(isMinimized) {
if (isMinimized) {
this.button.style.display = "none";
} else {
this.button.style.display = "";
}
}
destroy() {
this.button.removeEventListener("click", this.handleClick.bind(this));
this.button.remove();
}
}
class PlayPauseButton {
constructor(container, videoManager) {
__publicField(this, "playButton");
__publicField(this, "container");
__publicField(this, "videoManager", null);
this.container = container;
this.videoManager = videoManager || null;
this.createButton();
}
createButton() {
this.playButton = document.createElement("button");
this.playButton.className = "sf-controls-container__play-button";
this.playButton.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5v14l11-7z" fill="currentColor"/>
</svg>
`;
this.playButton.addEventListener("click", this.handlePlayClick.bind(this));
this.container.appendChild(this.playButton);
}
handlePlayClick(event) {
if (event) {
event.stopPropagation();
}
const store = useSaltfishStore.getState();
if (store.currentState === "autoplayBlocked") {
if (this.videoManager) {
this.videoManager.markUserInteraction();
this.videoManager.setMuted(false);
const videoElement = this.videoManager.getVideoElement();
if (videoElement) {
videoElement.loop = false;
videoElement.currentTime = 0;
}
} else {
const rootPlayer = this.playButton.closest(".sf-player");
if (rootPlayer) {
const videoElement = rootPlayer.querySelector(".sf-video-container__video");
if (videoElement) {
videoElement.muted = false;
videoElement.loop = false;
videoElement.currentTime = 0;
}
}
}
} else {
if (this.videoManager) {
this.videoManager.markUserInteraction();
} else {
console.warn("PlayPauseButton: VideoManager not available, falling back to store play");
}
}
if (store.currentState === "paused" || store.currentState === "waitingForInteraction" || store.currentState === "autoplayBlocked") {
store.play();
}
}
updateState(state) {
switch (state) {
case "playing":
this.playButton.style.display = "none";
break;
case "paused":
case "waitingForInteraction":
case "autoplayBlocked":
case "minimized":
case "idle":
case "loading":
case "error":
case "completed":
default:
this.playButton.style.display = "flex";
break;
}
}
destroy() {
this.playButton.removeEventListener("click", this.handlePlayClick.bind(this));
this.playButton.remove();
}
}
class ExitButton {
constructor(playerElement) {
__publicField(this, "button");
__publicField(this, "playerElement");
this.playerElement = playerElement;
this.button = document.createElement("button");
this.button.className = "sf-player__exit-button";
this.button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
this.button.addEventListener("click", this.handleClick.bind(this));
this.playerElement.appendChild(this.button);
this.updateVisibility(useSaltfishStore.getState().isMinimized);
}
handleClick(e) {
e.stopPropagation();
const store = useSaltfishStore.getState();
if (store.manifest) {
const player2 = SaltfishPlayer.getInstance();
player2.eventManager.trigger("playlistDismissed", {
timestamp: Date.now(),
playlist: {
id: store.manifest.id,
title: store.manifest.name
}
});
}
const player = SaltfishPlayer.getInstance();
player.destroy();
}
updateVisibility(isMinimized) {
if (isMinimized) {
this.button.style.display = "flex";
} else {
this.button.style.display = "none";
}
}
destroy() {
this.button.removeEventListener("click", this.handleClick.bind(this));
this.button.remove();
}
}
class UIManager {
constructor(shadowDOMManager) {
__publicField(this, "shadowDOMManager");
__publicField(this, "playerView");
__publicField(this, "dragManager");
__publicField(this, "playerRoot", null);
__publicField(this, "playerElement", null);
__publicField(this, "minimizeButton", null);
__publicField(this, "exitButton", null);
__publicField(this, "playPauseButton", null);
this.shadowDOMManager = shadowDOMManager;
this.playerView = new PlayerView();
this.dragManager = new DragManager();
}
/**
* Creates the complete player UI using the new decoupled structure
*/
createPlayerUI(videoManager, cursorManager, interactionManager, buttonManager) {
if (!this.playerRoot) {
this.shadowDOMManager.create();
this.playerRoot = this.shadowDOMManager.getRootElement();
if (!this.playerRoot) {
console.error("Failed to create player root element");
return;
}
}
if (this.playerElement) {
return;
}
this.playerElement = this.playerView.create(this.playerRoot);
this.minimizeButton = new MinimizeButton(this.playerElement);
this.exitButton = new ExitButton(this.playerElement);
videoManager.create(this.playerElement);
const controlsContainer = this.playerView.createControlsContainer(this.playerElement);
this.playPauseButton = new PlayPauseButton(controlsContainer, videoManager);
this.playerView.createSaltfishLogo(this.playerElement);
this.setupVideoContainerClickHandler();
cursorManager.create();
interactionManager.create(this.playerElement);
buttonManager.initialize(this.playerElement, videoManager);
const store = useSaltfishStore.getState();
this.updateControlsVisibility(store.currentState);
this.handleMinimizeStateChange(store.isMinimized);
this.dragManager.initialize(
this.playerElement,
this.playerRoot,
() => interactionManager.updateButtonPositions()
);
}
/**
* Sets up click handler for video container
*/
setupVideoContainerClickHandler() {
var _a;
const videoContainer = (_a = this.playerElement) == null ? void 0 : _a.querySelector(".sf-video-container");
if (videoContainer) {
videoContainer.addEventListener("click", (event) => {
const store = useSaltfishStore.getState();
if (store.isMinimized) {
event.stopPropagation();
this.handleMinimizeClick();
return;
}
if (this.dragManager.hasJustFinishedDragging()) {
return;
}
const target = event.target;
const isButton = target.tagName === "BUTTON" || target.closest("button") || target.closest(".sf-controls-container");
if (isButton) {
return;
}
if (store.currentState === "playing") {
store.pause();
}
});
}
}
/**
* Updates the position of the player element based on store state
*/
updatePosition() {
var _a;
if (!this.playerRoot || !this.playerElement) {
console.warn("UIManager: updatePosition called but playerRoot or playerElement is null", {
playerRoot: !!this.playerRoot,
playerElement: !!this.playerElement
});
return;
}
const store = useSaltfishStore.getState();
if (store.position) {
let { x, y } = store.position;
let positionToUse = (_a = store.playlistOptions) == null ? void 0 : _a.position;
if (store.currentStepId && store.manifest) {
const currentStep = store.manifest.steps.find((step) => step.id === store.currentStepId);
if (currentStep == null ? void 0 : currentStep.position) {
positionToUse = currentStep.position;
if (PositionCalculator.shouldForceReposition(positionToUse, y, this.dragManager.isCurrentlyDragging())) {
x = DIMENSIONS.DEFAULT_MARGIN;
}
}
}
const constrainedPosition = PositionCalculator.applyConstraints({
x,
y,
position: positionToUse || "bottom-right",
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
playerWidth: getPlayerDimensions(store.isMinimized).width
});
const transforms = PositionCalculator.getTransforms(positionToUse || "bottom-right");
this.playerRoot.style.left = `${constrainedPosition.x}px`;
this.playerRoot.style.top = `${constrainedPosition.y}px`;
this.playerRoot.style.transform = `translate(${transforms.transformX}, ${transforms.transformY})`;
if (store.isMinimized) {
this.playerElement.classList.add(CSS_CLASSES.PLAYER_MINIMIZED);
} else {
this.playerElement.classList.remove(CSS_CLASSES.PLAYER_MINIMIZED);
}
}
}
/**
* Handles minimize button click
*/
handleMinimizeClick() {
if (!this.playerElement) return;
const store = useSaltfishStore.getState();
const isMinimized = !store.isMinimized;
if (isMinimized) {
store.minimize();
if (store.currentState === "playing") {
store.pause();
}
} else {
store.maximize();
}
if (isMinimized) {
this.minimizeButton.minimize();
} else {
this.minimizeButton.maximize();
}
}
/**
* Handles minimize state changes and updates button visibility
*/
handleMinimizeStateChange(isMinimized) {
if (this.exitButton) {
this.exitButton.updateVisibility(isMinimized);
}
if (this.minimizeButton) {
this.minimizeButton.updateVisibility(isMinimized);
}
}
/**
* Updates the visibility of player controls based on the current state
* @param state - The current player state
*/
updateControlsVisibility(state) {
if (this.playPauseButton) {
this.playPauseButton.updateState(state);
}
}
/**
* Gets the player element
*/
getPlayerElement() {
return this.playerElement;
}
/**
* Gets the player root element
*/
getPlayerRoot() {
return this.playerRoot;
}
/**
* Checks if the UI is currently being dragged
*/
isCurrentlyDragging() {
return this.dragManager.isCurrentlyDragging();
}
/**
* Checks if dragging just finished
*/
hasJustFinishedDragging() {
return this.dragManager.hasJustFinishedDragging();
}
/**
* Resets drag state
*/
resetDragState() {
this.dragManager.resetDragState();
}
/**
* Destroys the UI and cleans up resources
*/
destroy() {
if (this.minimizeButton) {
this.minimizeButton.destroy();
this.minimizeButton = null;
}
if (this.exitButton) {
this.exitButton.destroy();
this.exitButton = null;
}
if (this.playPauseButton) {
this.playPauseButton.destroy();
this.playPauseButton = null;
}
this.playerView.destroy();
this.dragManager.destroy();
this.shadowDOMManager.remove();
this.playerRoot = null;
this.playerElement = null;
}
}
function setupUIUpdater(playerElement, cursorManager) {
let minimizeButton = null;
const findMinimizeButton = () => {
minimizeButton = playerElement.querySelector(".sf-player__minimize-button");
};
findMinimizeButton();
let prevState = {
currentState: "",
isMinimized: false,
currentStepId: null
};
const unsubscribe = useSaltfishStore.subscribe(
(state) => {
if (!minimizeButton) {
findMinimizeButton();
}
if (state.currentState !== prevState.currentState) {
updateStateClass(state.currentState);
}
if (state.isMinimized !== prevState.isMinimized) {
updateMinimizeState(state.isMinimized, state.currentStepId, state.manifest);
updateMinimizeButtonIcon(state.isMinimized);
}
if (state.currentStepId !== prevState.currentStepId) {
if (!state.isMinimized) {
updateCursorForStep(state.currentStepId, state.manifest);
}
}
prevState = {
currentState: state.currentState,
isMinimized: state.isMinimized,
currentStepId: state.currentStepId
};
}
);
function updateStateClass(currentState) {
const stateClasses = [
"sf-player--idle",
"sf-player--loading",
"sf-player--playing",
"sf-player--paused",
"sf-player--waitingForInteraction",
"sf-player--autoplayBlocked",
"sf-player--error",
"sf-player--completed"
];
stateClasses.forEach((cls) => {
playerElement.classList.remove(cls);
});
playerElement.classList.add(`sf-player--${currentState}`);
}
function updateMinimizeState(isMinimized, currentStepId, manifest) {
if (isMinimized) {
playerElement.classList.add("sf-player--minimized");
if (cursorManager) {
cursorManager.setShouldShowCursor(false);
}
} else {
playerElement.classList.remove("sf-player--minimized");
updateCursorForStep(currentStepId, manifest);
}
}
function updateCursorForStep(currentStepId, manifest) {
var _a;
if (!cursorManager) return;
const currentStep = (_a = manifest == null ? void 0 : manifest.steps) == null ? void 0 : _a.find((step) => step.id === currentStepId);
if (currentStep && currentStep.cursorAnimations && currentStep.cursorAnimations.length > 0) {
cursorManager.setShouldShowCursor(true);
}
}
function updateMinimizeButtonIcon(isMinimized) {
if (!minimizeButton) return;
if (isMinimized) {
minimizeButton.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
} else {
minimizeButton.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
}
}
return unsubscribe;
}
function setupEventUpdater(eventManager) {
let prevCurrentState = null;
let prevIsMinimized = null;
let prevStepId = null;
const unsubscribe = useSaltfishStore.subscribe(
(state) => {
var _a;
const data = {
previousState: prevCurrentState,
currentState: state.currentState,
currentStepId: state.currentStepId,
isMinimized: state.isMinimized
};
if (prevCurrentState === state.currentState && prevIsMinimized === state.isMinimized && prevStepId === state.currentStepId) {
return;
}
log(`EventUpdater: Processing state change from '${data.previousState}' to '${data.currentState}'`, {
manifestId: (_a = state.manifest) == null ? void 0 : _a.id
});
handleStateTransitionEvents(data, state, eventManager);
handleMinimizeEvents(data, state, eventManager, prevIsMinimized);
handleStepEvents(data, state, eventManager, prevStepId);
handleErrorEvents(data, state, eventManager);
prevCurrentState = state.currentState;
prevIsMinimized = state.isMinimized;
prevStepId = state.currentStepId;
}
);
return unsubscribe;
}
function handleStateTransitionEvents(data, store, eventManager) {
const { previousState, currentState } = data;
if (currentState === "playing" && previousState !== "playing" && previousState !== "paused" && store.manifest) {
eventManager.trigger("playlistStarted", {
timestamp: Date.now(),
playlist: {
id: store.manifest.id,
title: store.manifest.name
}
});
}
if (previousState === "paused" && currentState === "playing") {
eventManager.trigger("playerResumed", {
timestamp: Date.now(),
previousState,
currentState
});
} else if (previousState === "playing" && currentState === "paused") {
eventManager.trigger("playerPaused", {
timestamp: Date.now(),
previousState,
currentState
});
} else if (currentState === "completed" && previousState !== "completed") {
if (store.manifest) {
eventManager.trigger("playlistEnded", {
timestamp: Date.now(),
playlist: {
id: store.manifest.id,
title: store.manifest.name
}
});
}
}
}
function handleMinimizeEvents(data, _store, eventManager, prevIsMinimized) {
const { previousState, currentState, isMinimized } = data;
const wasPreviouslyMinimized = prevIsMinimized || false;
if (!isMinimized && wasPreviouslyMinimized) {
eventManager.trigger("playerMaximized", {
timestamp: Date.now(),
previousState,
currentState
});
} else if (isMinimized && !wasPreviouslyMinimized) {
eventManager.trigger("playerMinimized", {
timestamp: Date.now(),
previousState,
currentState
});
}
}
function handleStepEvents(data, store, eventManager, prevStepId) {
var _a, _b, _c;
const { currentStepId, currentState, previousState } = data;
const currentStep = store.currentStepId ? (((_a = store.manifest) == null ? void 0 : _a.steps) || []).find((s) => s.id === store.currentStepId) : null;
log(`EventUpdater.handleStepEvents: Processing step events`, {
currentStep: currentStep == null ? void 0 : currentStep.id,
manifestId: (_b = store.manifest) == null ? void 0 : _b.id,
hasManifest: !!store.manifest
});
const shouldTriggerStepEnded = prevStepId && store.manifest && (prevStepId !== currentStepId || currentState === "waitingForInteraction" || currentState === "autoplayBlocked" || currentState === "completed");
log(`EventUpdater.handleStepEvents: Step ended check`, {
hasManifest: !!store.manifest
});
if (shouldTriggerStepEnded) {
const prevStep = (((_c = store.manifest) == null ? void 0 : _c.steps) || []).find((s) => s.id === prevStepId);
log(`EventUpdater.handleStepEvents: Found previous step`, {
prevStep: prevStep == null ? void 0 : prevStep.id
});
if (prevStep) {
log(`EventUpdater.handleStepEvents: Triggering stepEnded for ${prevStep.id}`);
eventManager.trigger("stepEnded", {
timestamp: Date.now(),
step: {
id: prevStep.id,
title: prevStep.title || prevStep.id
},
playlist: {
id: store.manifest.id,
title: store.manifest.name
}
});
}
}
const isStepChange = currentStepId !== prevStepId && currentState === "playing";
const isSpecialTransition = (previousState === "paused" || previousState === "autoplayBlocked") && currentState === "playing" && prevStepId === currentStepId;
const shouldTriggerStepStarted = currentStep && store.manifest && (isStepChange || isSpecialTransition);
log(`EventUpdater.handleStepEvents: Step started check`, {
currentStep: currentStep == null ? void 0 : currentStep.id,
hasManifest: !!store.manifest
});
if (shouldTriggerStepStarted) {
log(`EventUpdater.handleStepEvents: Triggering stepStarted for ${currentStep.id}`);
eventManager.trigger("stepStarted", {
timestamp: Date.now(),
step: {
id: currentStep.id,
title: currentStep.title || currentStep.id
},
playlist: {
id: store.manifest.id,
title: store.manifest.name
}
});
}
}
function handleErrorEvents(data, store, eventManager) {
var _a;
const { currentState, previousState } = data;
if (currentState === "error" && previousState !== "error" && store.error) {
eventManager.trigger("error", {
timestamp: Date.now(),
playlistId: (_a = store.manifest) == null ? void 0 : _a.id,
stepId: store.currentStepId,
error: store.error,
errorType: "state"
});
}
}
function resetEventUpdater() {
}
class ErrorHandler {
/**
* Handles an error with consistent logging, reporting, and response
* @param error The error to handle (Error object, string, or unknown)
* @param context Context information about where the error occurred
* @param options Options for how to handle the error
*/
static handle(error2, context = {}, options = {}) {
var _a;
const finalOptions = { ...this.DEFAULT_OPTIONS, ...options };
const normalizedError = this.normalizeError(error2, context);
if (finalOptions.shouldLog) {
this.logError(normalizedError, context, finalOptions.severity);
}
if (finalOptions.shouldUpdateStore) {
this.updateStore(normalizedError);
}
if (finalOptions.shouldTriggerEvent) {
this.triggerErrorEvent(normalizedError, context, (_a = context.component) == null ? void 0 : _a.toLowerCase());
}
if (finalOptions.shouldDestroy) {
this.destroyPlayer();
}
if (finalOptions.shouldThrow) {
throw normalizedError;
}
return normalizedError;
}
/**
* Handles initialization errors
*/
static handleInitializationError(error2, context = {}) {
return this.handle(error2, { ...context, component: "Initialization" }, {
severity: "critical",
shouldLog: true,
shouldUpdateStore: true,
shouldDestroy: true,
shouldThrow: true
});
}
/**
* Handles playlist loading errors
*/
static handlePlaylistError(error2, context = {}) {
return this.handle(error2, { ...context, component: "Playlist" }, {
severity: "error",
shouldLog: true,
shouldUpdateStore: true,
shouldTriggerEvent: true
});
}
/**
* Handles video loading/playback errors
*/
static handleVideoError(error2, context = {}) {
return this.handle(error2, { ...context, component: "Video" }, {
severity: "error",
shouldLog: true,
shouldUpdateStore: true,
shouldTriggerEvent: true
});
}
/**
* Handles network/API errors
*/
static handleNetworkError(error2, context = {}) {
return this.handle(error2, { ...context, component: "Network" }, {
severity: "warning",
shouldLog: true,
shouldTriggerEvent: true
});
}
/**
* Handles non-critical errors (warnings)
*/
static handleWarning(error2, context = {}) {
return this.handle(error2, context, {
severity: "warning",
shouldLog: true,
shouldThrow: false
});
}
/**
* Handles cleanup/destroy errors
*/
static handleCleanupError(error2, context = {}) {
return this.handle(error2, { ...context, component: "Cleanup" }, {
severity: "warning",
shouldLog: true,
shouldThrow: false
});
}
/**
* Normalizes different error types to Error objects
*/
static normalizeError(error2, context) {
if (error2 instanceof Error) {
return error2;
}
if (typeof error2 === "string") {
return new Error(error2);
}
const errorString = error2 && typeof error2 === "object" && "message" in error2 ? String(error2.message) : String(error2);
return new Error(`Unknown error in ${context.component || "application"}: ${errorString}`);
}
/**
* Formats error message with context information
*/
static formatErrorMessage(error2, context) {
const parts = [];
if (context.component) {
parts.push(`[${context.component}]`);
}
if (context.method) {
parts.push(`${context.method}:`);
}
parts.push(error2.message);
return parts.join(" ");
}
/**
* Logs error with appropriate severity level
*/
static logError(error2, context, severity) {
const message = this.formatErrorMessage(error2, context);
const logData = {
error: {
name: error2.name,
message: error2.message,
stack: error2.stack
},
context,
severity,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
switch (severity) {
case "info":
break;
case "warning":
console.warn(message, logData);
break;
case "error":
case "critical":
console.error(message, logData);
break;
}
}
/**
* Updates store with error state
*/
static updateStore(error2) {
try {
const store = useSaltfishStore.getState();
store.setError(error2);
} catch (storeError) {
console.error("Failed to update store with error:", storeError);
}
}
/**
* Triggers error event through EventManager
*/
static triggerErrorEvent(error2, context, errorType) {
var _a, _b;
try {
const store = useSaltfishStore.getState();
if (typeof window !== "undefined" && window._saltfishPlayer) {
const player = window._saltfishPlayer;
if (player && player.eventManager) {
player.eventManager.trigger("error", {
timestamp: Date.now(),
playlistId: context.playlistId || ((_a = store.manifest) == null ? void 0 : _a.id),
stepId: context.stepId || store.currentStepId,
error: error2,
errorType: context.errorType || errorType || ((_b = context.component) == null ? void 0 : _b.toLowerCase()) || "unknown"
});
}
}
} catch (eventError) {
console.error("Failed to trigger error event:", eventError);
}
}
/**
* Destroys player for critical errors
*/
static destroyPlayer() {
try {
if (typeof window !== "undefined" && window._saltfishPlayer) {
const player = window._saltfishPlayer;
if (player && typeof player.destroy === "function") {
player.destroy();
}
}
} catch (destroyError) {
console.error("Failed to destroy player during error handling:", destroyError);
}
}
/**
* Creates a standardized error with context
*/
static createError(message, context = {}) {
const error2 = new Error(this.formatErrorMessage(new Error(message), context));
if (context.component) error2.component = context.component;
if (context.method) error2.method = context.method;
if (context.playlistId) error2.playlistId = context.playlistId;
if (context.stepId) error2.stepId = context.stepId;
return error2;
}
/**
* Checks if an error is recoverable based on its type and context
*/
static isRecoverable(error2) {
if (error2.message.includes("fetch") || error2.message.includes("network") || error2.message.includes("timeout")) {
return true;
}
const recoverablePatterns = [
/autoplay.*blocked/i,
/video.*failed.*load/i,
/manifest.*not.*found/i
];
return recoverablePatterns.some((pattern) => pattern.test(error2.message));
}
/**
* Safely executes a function with error handling
*/
static async safeExecute(fn, context = {}, options = {}) {
try {
return await fn();
} catch (error2) {
this.handle(error2, context, options);
return null;
}
}
}
__publicField(ErrorHandler, "DEFAULT_OPTIONS", {
severity: "error",
shouldLog: true,
shouldThrow: false,
shouldUpdateStore: false,
shouldTriggerEvent: false,
shouldDestroy: false
});
const _StepTimeoutManager = class _StepTimeoutManager {
constructor(destroyCallback) {
__publicField(this, "currentStepId", null);
__publicField(this, "stepTimeoutId", null);
__publicField(this, "destroyCallback", null);
this.destroyCallback = destroyCallback;
}
/**
* Update method - called when player state changes
*/
update(data) {
const { currentStepId, currentState } = data;
log(`StepTimeoutManager: State update - currentState: ${currentState}, currentStepId: ${currentStepId}, previousStepId: ${this.currentStepId}`);
const isActiveState = currentState === "playing" || currentState === "waitingForInteraction" || currentState === "paused" || currentState === "autoplayBlocked";
if (!isActiveState) {
if (this.stepTimeoutId !== null) ;
this.clearStepTimeout();
return;
}
const stepChanged = currentStepId !== this.currentStepId;
const noTimeoutRunning = this.stepTimeoutId === null;
if (stepChanged || noTimeoutRunning) {
if (stepChanged) {
log(`StepTimeoutManager: Step changed from ${this.currentStepId} to ${currentStepId}`);
}
this.setStepTimeout(currentStepId);
}
}
/**
* Sets or resets the step timeout
*/
setStepTimeout(stepId) {
this.clearStepTimeout();
if (!stepId) {
return;
}
this.currentStepId = stepId;
this.stepTimeoutId = window.setTimeout(() => {
this.destroyPlayer();
}, _StepTimeoutManager.STEP_TIMEOUT_MS);
}
/**
* Clears the current step timeout
*/
clearStepTimeout() {
if (this.stepTimeoutId !== null) {
log(`StepTimeoutManager: Clearing step timeout for step ${this.currentStepId}`);
window.clearTimeout(this.stepTimeoutId);
this.stepTimeoutId = null;
}
}
/**
* Destroys the player safely
*/
destroyPlayer() {
if (this.destroyCallback) {
try {
log("StepTimeoutManager: Calling destroy callback due to step timeout");
this.destroyCallback();
} catch (error2) {
}
}
}
/**
* Resets the timeout manager
*/
reset() {
this.clearStepTimeout();
this.currentStepId = null;
}
/**
* Destroys the timeout manager and cleans up resources
*/
destroy() {
this.clearStepTimeout();
this.currentStepId = null;
this.destroyCallback = null;
}
};
__publicField(_StepTimeoutManager, "STEP_TIMEOUT_MS", TIMING.STEP_TIMEOUT);
let StepTimeoutManager = _StepTimeoutManager;
const _SaltfishPlayer = class _SaltfishPlayer {
/**
* Creates a new Saltfish playlist Player instance
* @private Constructor is private to enforce singleton pattern
*/
constructor() {
__publicField(this, "shadowDOMManager");
__publicField(this, "videoManager");
__publicField(this, "cursorManager");
__publicField(this, "interactionManager");
__publicField(this, "analyticsManager");
__publicField(this, "sessionRecordingManager");
__publicField(this, "sessionManager");
__publicField(this, "buttonManager");
__publicField(this, "transitionManager");
__publicField(this, "triggerManager");
__publicField(this, "eventManager");
__publicField(this, "playlistManager");
__publicField(this, "stepTimeoutManager");
__publicField(this, "uiManager");
__publicField(this, "playerView");
__publicField(this, "dragManager");
// Store updater unsubscribe functions
__publicField(this, "uiUpdaterUnsubscribe", null);
__publicField(this, "eventUpdaterUnsubscribe", null);
__publicField(this, "isInitialized", false);
// Store the last config so we can reinitialize if needed
__publicField(this, "lastConfig", null);
// Store the last user identification to restore after reinitialization
__publicField(this, "lastUserIdentification", null);
/**
* Handles store state changes
*/
__publicField(this, "handleStoreChanges", () => {
if (!this.isInitialized) return;
const store = useSaltfishStore.getState();
if (store.currentState === _SaltfishPlayer.prevState.currentState && store.currentStepId === _SaltfishPlayer.prevState.currentStepId && store.isMinimized === _SaltfishPlayer.prevState.isMinimized) {
return;
}
debug("SaltfishPlayer: Store state changed", {
prevState: _SaltfishPlayer.prevState.currentState,
newState: store.currentState,
prevStepId: _SaltfishPlayer.prevState.currentStepId,
newStepId: store.currentStepId,
prevMinimized: _SaltfishPlayer.prevState.isMinimized,
newMinimized: store.isMinimized
});
if (this.uiManager.getPlayerRoot() && this.uiManager.getPlayerElement()) {
this.uiManager.updatePosition();
}
this.updateControlsVisibility(store.currentState);
if (store.currentState === "autoplayBlocked") {
this.buttonManager.updatePlayPauseButton("autoplayBlocked");
} else if (store.currentState === "completed") {
this.handleCompletedState();
}
if (store.isMinimized !== _SaltfishPlayer.prevState.isMinimized) {
this.uiManager.handleMinimizeStateChange(store.isMinimized);
}
_SaltfishPlayer.prevState = {
currentState: store.currentState,
currentStepId: store.currentStepId,
isMinimized: store.isMinimized
};
});
if (_SaltfishPlayer.instance) {
throw new Error("SaltfishPlayer is a singleton. Use getInstance()");
}
this.sessionManager = new SessionManager();
this.shadowDOMManager = new ShadowDOMManager();
this.videoManager = new VideoManager();
this.eventManager = new EventManager();
this.analyticsManager = new AnalyticsManager(this.eventManager);
this.sessionRecordingManager = new SessionRecordingManager();
this.playlistManager = new PlaylistManager(this.eventManager);
this.cursorManager = new CursorManager();
this.interactionManager = new InteractionManager();
this.buttonManager = new ButtonManager();
this.transitionManager = new TransitionManager();
this.triggerManager = new TriggerManager();
this.stepTimeoutManager = new StepTimeoutManager(() => this.destroy());
this.playerView = new PlayerView();
this.dragManager = new DragManager();
this.transitionManager.setTriggerManager(this.triggerManager);
this.uiManager = new UIManager(this.shadowDOMManager);
useSaltfishStore.subscribe(this.handleStoreChanges.bind(this));
this.registerStateMachineActions();
}
/**
* Gets the singleton instance of the Saltfish playlist Player
*/
static getInstance() {
if (!_SaltfishPlayer.instance) {
_SaltfishPlayer.instance = new _SaltfishPlayer();
}
return _SaltfishPlayer.instance;
}
/**
* Gets the current sessionId (persistent across 30 minutes)
* @returns The current sessionId
*/
getSessionId() {
return this.sessionManager.getSessionId();
}
/**
* Gets the current runId (unique per playlist execution)
* @returns The current runId or null if no playlist is running
*/
getRunId() {
return this.sessionManager.getCurrentRunId();
}
/**
* Initializes the Saltfish playlist Player
* @param config - Configuration options
*/
async initialize(config) {
var _a, _b, _c;
if (this.isInitialized) {
console.warn("Saltfish playlist Player is already initialized");
return;
}
try {
const response = await fetch(`${API.BASE_URL}/validate-token`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ token: config.token })
});
const data = await response.json();
debug("[SaltfishPlayer.initialize] Token validation response data:", data);
if (!data.isValid) {
throw ErrorHandler.handleInitializationError(
data.error || "Token validation failed",
{
component: "SaltfishPlayer",
method: "initialize",
additionalData: { token: ((_a = config.token) == null ? void 0 : _a.substring(0, 10)) + "..." }
}
);
}
const updatedConfig = {
...config,
showLogo: data.showLogo !== false
// Default to true if not specified
};
this.lastConfig = updatedConfig;
const store = useSaltfishStore.getState();
store.initialize(updatedConfig);
const userId = ((_b = store.user) == null ? void 0 : _b.id) || ((_c = this.lastUserIdentification) == null ? void 0 : _c.userId);
this.analyticsManager.initialize(config, this.sessionManager.getSessionId());
this.sessionRecordingManager.initialize(config, this.sessionManager.getSessionId(), userId);
if (data.playlists && Array.isArray(data.playlists) && store.setBackendPlaylists) {
debug("[SaltfishPlayer.initialize] Found data.playlists, attempting to store:", data.playlists);
store.setBackendPlaylists(data.playlists);
debug("[SaltfishPlayer.initialize] Successfully called setBackendPlaylists with data.playlists.");
this.triggerManager.registerTriggers(data.playlists);
debug("[SaltfishPlayer.initialize] Registered autoStart triggers");
} else {
throw ErrorHandler.handleInitializationError(
"Backend validation successful, but no playlists array provided in the response. Cannot initialize player.",
{
component: "SaltfishPlayer",
method: "initialize",
additionalData: { responseData: data }
}
);
}
this.isInitialized = true;
window._saltfishPlayer = this;
window._cursorManager = this.cursorManager;
this.eventManager.trigger("initialized", {
timestamp: Date.now()
});
} catch (error2) {
throw ErrorHandler.handleInitializationError(
error2,
{
component: "SaltfishPlayer",
method: "initialize"
}
);
}
}
/**
* Identifies the current user
* @param userId - User ID
* @param userData - Additional user data
*/
identifyUser(userId, userData) {
this.lastUserIdentification = { userId, userData };
const store = useSaltfishStore.getState();
store.identifyUser(userId, userData);
this.analyticsManager.setUser({
id: userId,
...userData
});
this.sessionRecordingManager.identifyUser({
id: userId,
...userData
});
this.fetchUserData(userId, userData);
}
/**
* Identifies the current user anonymously (localStorage only, no backend communication)
* Automatically generates a persistent anonymous user ID stored in localStorage
* @param userData - Optional additional user data
*/
identifyAnonymous(userData) {
const userId = this.getOrCreateAnonymousUserId();
this.lastUserIdentification = { userId, userData };
const store = useSaltfishStore.getState();
store.identifyUser(userId, { ...userData, __isAnonymous: true });
this.analyticsManager.setUser({
id: userId,
...userData
});
this.sessionRecordingManager.identifyUser({
id: userId,
...userData
});
this.loadAnonymousUserData(userId, userData);
}
/**
* Fetches user data from the backend
* @param userId - User ID
* @param userData - Additional user data
*/
async fetchUserData(userId, userData) {
var _a;
try {
const store = useSaltfishStore.getState();
if (!((_a = store.config) == null ? void 0 : _a.token)) {
ErrorHandler.handleWarning(
"Cannot fetch user data: Token not available",
{
component: "SaltfishPlayer",
method: "fetchUserData",
userId
}
);
return;
}
debug("[SaltfishPlayer.fetchUserData] Fetching user data for userId:", userId);
const response = await fetch(`https://player.saltfish.ai/clients/${store.config.token}/users/${userId}`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ userData })
});
if (!response.ok) {
const errorText = await response.text();
debug("[SaltfishPlayer.fetchUserData] Failed to fetch user data:", {
status: response.status,
statusText: response.statusText,
error: errorText
});
return;
}
const data = await response.json();
debug("[SaltfishPlayer.fetchUserData] User data fetched successfully:", data);
if (data.success) {
store.setUserData({
watchedPlaylists: data.watchedPlaylists || {}
});
this.eventManager.trigger("userDataLoaded", {
timestamp: Date.now(),
userId,
userData: {
watchedPlaylists: data.watchedPlaylists || {}
}
});
this.triggerManager.startMonitoring();
debug("[SaltfishPlayer.fetchUserData] Started autoStart trigger monitoring");
} else {
debug("[SaltfishPlayer.fetchUserData] Backend returned unsuccessful response:", data);
}
} catch (error2) {
ErrorHandler.handleNetworkError(
error2,
{
component: "SaltfishPlayer",
method: "fetchUserData",
userId
}
);
}
}
/**
* Loads user data from localStorage for anonymous identification
* @param userId - User ID
* @param userData - Additional user data
*/
loadAnonymousUserData(userId, userData) {
try {
if (typeof window === "undefined") {
return;
}
debug("[SaltfishPlayer.loadAnonymousUserData] Loading anonymous user data for userId:", userId);
const existingDataStr = localStorage.getItem(STORAGE_KEYS.ANONYMOUS_USER);
let anonymousUserData = {
userId,
userData: userData || {},
watchedPlaylists: {},
timestamp: Date.now()
};
if (existingDataStr) {
try {
const existingData = JSON.parse(existingDataStr);
anonymousUserData = {
userId,
userData: { ...existingData.userData, ...userData },
watchedPlaylists: existingData.watchedPlaylists || {},
timestamp: Date.now()
};
debug("[SaltfishPlayer.loadAnonymousUserData] Loaded existing anonymous user data:", anonymousUserData);
} catch (parseError) {
debug("[SaltfishPlayer.loadAnonymousUserData] Error parsing existing anonymous data, using defaults:", parseError);
}
}
localStorage.setItem(STORAGE_KEYS.ANONYMOUS_USER, JSON.stringify(anonymousUserData));
const store = useSaltfishStore.getState();
store.setUserData({
watchedPlaylists: anonymousUserData.watchedPlaylists || {}
});
this.eventManager.trigger("userDataLoaded", {
timestamp: Date.now(),
userId,
userData: {
watchedPlaylists: anonymousUserData.watchedPlaylists || {}
}
});
this.triggerManager.startMonitoring();
debug("[SaltfishPlayer.loadAnonymousUserData] Started autoStart trigger monitoring with localStorage data");
} catch (error2) {
ErrorHandler.handleNetworkError(
error2,
{
component: "SaltfishPlayer",
method: "loadAnonymousUserData",
userId
}
);
}
}
/**
* Gets or creates a persistent anonymous user ID
* @returns A persistent anonymous user ID
*/
getOrCreateAnonymousUserId() {
if (typeof window === "undefined") {
return `anonymous_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
const ANONYMOUS_USER_ID_KEY = "saltfish_anonymous_user_id";
try {
let anonymousUserId = localStorage.getItem(ANONYMOUS_USER_ID_KEY);
if (!anonymousUserId) {
anonymousUserId = `anonymous_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
localStorage.setItem(ANONYMOUS_USER_ID_KEY, anonymousUserId);
debug(`[SaltfishPlayer.getOrCreateAnonymousUserId] Created new anonymous user ID: ${anonymousUserId}`);
} else {
debug(`[SaltfishPlayer.getOrCreateAnonymousUserId] Using existing anonymous user ID: ${anonymousUserId}`);
}
return anonymousUserId;
} catch (error2) {
console.warn("SaltfishPlayer: localStorage not available, using session-only anonymous ID");
return `anonymous_session_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
}
/**
* Starts a new playlist
* @param playlistId - ID of the playlist to start
* @param options - Optional playback options including position, drag behavior, and starting step
* @param options.position - Position of the player on screen
* @param options.allowDrag - Whether the player can be dragged
* @param options.startNodeId - Optional step ID to start from instead of the manifest's default startStep
*
* If the player has been destroyed, it will automatically reinitialize using the last configuration
* before starting the playlist.
*/
async startPlaylist(playlistId, options) {
var _a, _b, _c;
try {
const needsManagerRecreation = this.isInitialized && !this.uiManager.getPlayerElement();
if (!this.isInitialized && this.lastConfig) {
try {
await this.initialize(this.lastConfig);
if (this.lastUserIdentification) {
this.identifyUser(this.lastUserIdentification.userId, this.lastUserIdentification.userData);
}
this.videoManager = new VideoManager();
this.cursorManager = new CursorManager();
this.interactionManager = new InteractionManager();
this.buttonManager = new ButtonManager();
this.transitionManager = new TransitionManager();
this.playerView = new PlayerView();
this.dragManager = new DragManager();
this.uiManager = new UIManager(this.shadowDOMManager);
this.registerStateMachineActions();
} catch (reinitError) {
throw ErrorHandler.handleInitializationError(
`Failed to reinitialize player: ${reinitError instanceof Error ? reinitError.message : "Unknown error"}`,
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
additionalData: { reinitError }
}
);
}
}
if (needsManagerRecreation) {
this.videoManager = new VideoManager();
this.cursorManager = new CursorManager();
this.interactionManager = new InteractionManager();
this.buttonManager = new ButtonManager();
this.transitionManager = new TransitionManager();
this.playerView = new PlayerView();
this.dragManager = new DragManager();
this.uiManager = new UIManager(this.shadowDOMManager);
this.stepTimeoutManager = new StepTimeoutManager(() => this.cleanupPlaylist());
this.transitionManager.setTriggerManager(this.triggerManager);
this.registerStateMachineActions();
}
const store = useSaltfishStore.getState();
if (!store.config) {
if (!this.lastConfig) {
throw ErrorHandler.createError(
"Saltfish Player must be initialized at least once before starting a playlist",
{ component: "SaltfishPlayer", method: "startPlaylist", playlistId }
);
}
throw ErrorHandler.createError(
"Saltfish Player must be initialized before starting a playlist",
{ component: "SaltfishPlayer", method: "startPlaylist", playlistId }
);
}
const isPlaylistRunning = store.manifest && (store.currentState === "playing" || store.currentState === "paused" || store.currentState === "loading" || store.currentState === "waitingForInteraction" || store.currentState === "autoplayBlocked" || store.currentState === "minimized");
if (isPlaylistRunning) {
debug("SaltfishPlayer: Starting new playlist while another is running, resetting state");
this.cleanupCurrentPlaylist();
store.resetForNewPlaylist();
this.registerStateMachineActions();
}
if (store.currentState === "completed") {
this.registerStateMachineActions();
}
const runId = this.sessionManager.startNewRun();
debug(`SaltfishPlayer: Starting playlist ${playlistId} with runId: ${runId}`);
const backendPlaylists = store.backendPlaylists;
if (backendPlaylists && backendPlaylists.length > 0) {
const foundPlaylist = backendPlaylists.find((p) => p.id === playlistId);
if (foundPlaylist == null ? void 0 : foundPlaylist.autoStart) {
if (!store.user) {
ErrorHandler.handlePlaylistError(
"User must be identified before starting auto-start playlist",
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
errorType: "playlist_user_required"
}
);
return;
}
if (!store.userData) {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.eventManager.off("userDataLoaded", handler);
reject(new Error("Timeout waiting for user data"));
}, 5e3);
const handler = () => {
clearTimeout(timeout);
const updatedStore2 = useSaltfishStore.getState();
resolve();
};
this.eventManager.on("userDataLoaded", handler);
});
}
const currentStore = useSaltfishStore.getState();
const watchedPlaylists = ((_a = currentStore.userData) == null ? void 0 : _a.watchedPlaylists) || {};
if (((_b = foundPlaylist.triggers) == null ? void 0 : _b.once) && watchedPlaylists[playlistId]) {
info(`Playlist ${playlistId} has autoStart enabled with once:true and has already been watched. Skipping playlist start.`, {
watchedPlaylists,
triggers: foundPlaylist.triggers
});
return;
}
}
}
if (options == null ? void 0 : options.once) {
if (!store.user) {
ErrorHandler.handlePlaylistError(
"User must be identified before starting playlist with once option",
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
errorType: "playlist_auth_required"
}
);
return;
}
if (!store.userData) {
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.eventManager.off("userDataLoaded", handler);
reject(new Error("Timeout waiting for user data"));
}, 5e3);
const handler = () => {
clearTimeout(timeout);
const updatedStore2 = useSaltfishStore.getState();
resolve();
};
this.eventManager.on("userDataLoaded", handler);
});
}
const currentStore = useSaltfishStore.getState();
const watchedPlaylists = ((_c = currentStore.userData) == null ? void 0 : _c.watchedPlaylists) || {};
const playlistData = watchedPlaylists[playlistId];
if (playlistData && (playlistData.status === "completed" || playlistData.status === "dismissed")) {
info(`Playlist ${playlistId} has once option enabled and has already been ${playlistData.status}. Skipping playlist start.`, {
watchedPlaylists,
playlistStatus: playlistData.status
});
return;
} else if (playlistData && playlistData.status === "in_progress") {
if (!(options == null ? void 0 : options.startNodeId) && playlistData.currentStepId) {
options = {
...options || {},
startNodeId: playlistData.currentStepId
};
}
}
}
const finalOptions = options || {};
if (finalOptions) {
store.setPlaylistOptions(finalOptions);
if (finalOptions.position) {
this.uiManager.updatePosition();
} else {
store.setPlaylistOptions({
...finalOptions,
position: "bottom-right"
});
this.uiManager.updatePosition();
}
} else {
store.setPlaylistOptions({
position: "bottom-right",
allowDrag: true
});
this.uiManager.updatePosition();
}
const playlistPersistence = (options == null ? void 0 : options.persistence) ?? true;
if (playlistPersistence && typeof window !== "undefined") {
const savedProgress = localStorage.getItem("saltfish_progress");
if (savedProgress) {
try {
const progress = JSON.parse(savedProgress);
if (progress[playlistId]) {
store.loadPlaylistProgress(playlistId, progress[playlistId]);
}
} catch (e) {
console.warn("Failed to parse saved progress for playlist:", playlistId);
}
}
}
this.analyticsManager.trackPlaylistStart(playlistId);
this.cursorManager.resetFirstAnimation();
let manifestPathToLoad = "";
if (backendPlaylists && backendPlaylists.length > 0) {
const foundPlaylist = backendPlaylists.find((p) => p.id === playlistId);
if (foundPlaylist) {
if (!foundPlaylist.isLive) {
ErrorHandler.handlePlaylistError(
`Cannot start playlist '${playlistId}' - playlist is not currently live`,
{
playlistId,
additionalData: {
isLive: foundPlaylist.isLive,
triggers: foundPlaylist.triggers
}
}
);
return;
}
manifestPathToLoad = foundPlaylist.path;
debug(`[SaltfishPlayer.startPlaylist] Found matching playlist in backend list for id '${playlistId}'. Using path: ${manifestPathToLoad}`);
} else {
ErrorHandler.handlePlaylistError(
`Playlist ID '${playlistId}' not found in the list provided by backend validation`,
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
errorType: "playlist_not_found",
additionalData: { backendPlaylists }
}
);
return;
}
} else {
ErrorHandler.handlePlaylistError(
"No playlist list available from backend validation",
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
errorType: "playlist_backend_unavailable"
}
);
return;
}
if (!manifestPathToLoad) {
ErrorHandler.handlePlaylistError(
"Critical Error: Could not determine manifest path to load even after checks",
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
errorType: "playlist_manifest_failed"
}
);
return;
}
try {
const response = await fetch(manifestPathToLoad);
if (!response.ok) {
throw new Error(`Failed to fetch manifest: ${response.statusText}`);
}
const manifestData = await response.json();
const { isDeviceCompatible: isDeviceCompatible2 } = await Promise.resolve().then(() => deviceDetection);
if (manifestData.deviceType && !isDeviceCompatible2(manifestData.deviceType)) {
const deviceType = manifestData.deviceType;
ErrorHandler.handlePlaylistError(
`Playlist '${playlistId}' is not compatible with this device. Required: ${deviceType}`,
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
errorType: "device_incompatible",
additionalData: {
requiredDeviceType: deviceType
}
}
);
return;
}
} catch (error2) {
ErrorHandler.handlePlaylistError(
`Failed to check device compatibility: ${error2 instanceof Error ? error2.message : "Unknown error"}`,
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
errorType: "device_compatibility_check_failed"
}
);
return;
}
this.uiManager.createPlayerUI(this.videoManager, this.cursorManager, this.interactionManager, this.buttonManager);
this.setupUpdaters();
await this.playlistManager.load(manifestPathToLoad, { ...finalOptions, persistence: playlistPersistence });
const updatedStore = useSaltfishStore.getState();
if (finalOptions.startNodeId && updatedStore.manifest) {
const targetStep = updatedStore.manifest.steps.find((step) => step.id === finalOptions.startNodeId);
if (targetStep) {
updatedStore.goToStep(finalOptions.startNodeId);
} else {
console.warn(`[SaltfishPlayer] startNodeId '${finalOptions.startNodeId}' not found in manifest steps. Starting from default step.`);
}
} else if (updatedStore.manifest) {
}
if (updatedStore.manifest) {
if (updatedStore.manifest.cursorColor) {
this.cursorManager.setColor(updatedStore.manifest.cursorColor);
} else {
}
this.eventManager.trigger("playlistStarted", {
timestamp: Date.now(),
playlist: {
id: playlistId,
title: updatedStore.manifest.name
}
});
}
store.play();
info(`Playlist started: ${playlistId}${updatedStore.manifest ? ` (${updatedStore.manifest.name})` : ""}`);
} catch (error2) {
ErrorHandler.handlePlaylistError(
error2,
{
component: "SaltfishPlayer",
method: "startPlaylist",
playlistId,
errorType: "playlist_load_failed"
}
);
}
}
/**
* Registers action handlers with the state machine
*/
registerStateMachineActions() {
const store = useSaltfishStore.getState();
store.stateMachine.registerActions({
startVideoPlayback: (context) => {
if (!context.currentStep) return;
const videoUrl = this.getVideoUrl(context.currentStep);
try {
this.videoManager.showProgressBar();
this.videoManager.showMuteButton();
this.interactionManager.clearButtons();
this.interactionManager.clearDOMInteractions();
if (context.currentStep.domInteractions) {
this.interactionManager.setupDOMInteractions(context.currentStep.domInteractions);
}
if (context.currentStep.buttons) {
this.interactionManager.createButtons(context.currentStep.buttons);
}
debug(`SaltfishPlayer: Processing cursor animations for step ${context.currentStep.id}`);
debug(`SaltfishPlayer: Step has cursor animations: ${!!(context.currentStep.cursorAnimations && context.currentStep.cursorAnimations.length > 0)}`);
if (context.currentStep.cursorAnimations && context.currentStep.cursorAnimations.length > 0) {
debug(`SaltfishPlayer: Setting cursor visibility to true for step ${context.currentStep.id}`);
this.cursorManager.setShouldShowCursor(true);
debug(`SaltfishPlayer: Starting cursor animation for step ${context.currentStep.id} with target: ${context.currentStep.cursorAnimations[0].targetSelector || "no target"}`);
this.cursorManager.animate(context.currentStep.cursorAnimations[0]);
} else {
debug(`SaltfishPlayer: Setting cursor visibility to false for step ${context.currentStep.id} - step has no cursor animations`);
this.cursorManager.setShouldShowCursor(false);
}
const hasSpecialTransitions = context.currentStep.buttons && context.currentStep.buttons.length > 0 || context.currentStep.transitions.some(
(t) => t.type === "dom-click" || t.type === "url-path"
);
const completionPolicy = hasSpecialTransitions ? "manual" : "auto";
if (hasSpecialTransitions) {
debug(`SaltfishPlayer: Setting up transitions immediately for step with special transitions`);
this.transitionManager.setupTransitions(context.currentStep, false);
}
this.videoManager.setCompletionPolicy(completionPolicy, () => {
if (context.currentStep) {
if (!hasSpecialTransitions) {
debug(`SaltfishPlayer: Setting up transitions after video ended`);
this.transitionManager.setupTransitions(context.currentStep, true);
}
store.stateMachine.send({
type: "VIDEO_ENDED",
step: context.currentStep
});
}
});
this.videoManager.loadVideo(videoUrl).then(() => {
var _a;
debug(`SaltfishPlayer: Video loaded successfully, playing`);
if (context.currentStep && context.currentStep.transcript) {
debug(`SaltfishPlayer: Loading transcript for step ${context.currentStep.id}`);
this.videoManager.loadTranscript(context.currentStep.transcript);
} else {
debug(`SaltfishPlayer: No transcript available for step ${((_a = context.currentStep) == null ? void 0 : _a.id) || "unknown"}`);
this.videoManager.loadTranscript(null);
}
this.videoManager.play();
if (context.currentStep) {
const nextVideoUrl = this.findNextVideoUrl(context.currentStep);
if (nextVideoUrl) {
debug(`SaltfishPlayer: Preloading next video: ${nextVideoUrl}`);
this.videoManager.preloadNextVideo(nextVideoUrl);
}
}
}).catch((error2) => {
var _a, _b;
debug(`SaltfishPlayer: Error loading video: ${error2}`);
this.eventManager.trigger("error", {
timestamp: Date.now(),
playlistId: ((_a = store.manifest) == null ? void 0 : _a.id) || void 0,
stepId: (_b = context.currentStep) == null ? void 0 : _b.id,
error: error2 instanceof Error ? error2 : new Error(`Failed to load video: ${error2}`),
errorType: "video"
});
});
} catch (error2) {
}
},
pauseVideoPlayback: () => {
this.videoManager.pause();
},
startMutedLoopedVideo: () => {
const videoElement = this.videoManager.getVideoElement();
if (videoElement) {
videoElement.muted = true;
videoElement.loop = true;
videoElement.play().catch(() => {
});
}
this.videoManager.hideProgressBar();
this.videoManager.hideMuteButton();
},
trackPlaylistComplete: () => {
this.handleCompletedState();
},
handleError: (context) => {
var _a, _b;
debug(`SaltfishPlayer: Action handler - Handling error: ${(_a = context.error) == null ? void 0 : _a.message}`);
if (context.error && this.uiManager.getPlayerElement()) {
const errorElement = document.createElement("div");
errorElement.className = "saltfish-error";
errorElement.textContent = `Error: ${context.error.message}`;
const playerElement = this.uiManager.getPlayerElement();
if (playerElement) {
playerElement.innerHTML = "";
playerElement.appendChild(errorElement);
}
this.eventManager.trigger("error", {
timestamp: Date.now(),
playlistId: ((_b = store.manifest) == null ? void 0 : _b.id) || void 0,
stepId: store.currentStepId || void 0,
error: context.error,
errorType: "player"
});
}
}
});
}
/**
* Finds the URL of the next video in the playlist
* @param currentStep - The current step being played
* @returns The URL of the next video or null if no next video exists
*/
findNextVideoUrl(currentStep) {
const store = useSaltfishStore.getState();
if (!store.manifest || !currentStep) return null;
if (currentStep.transitions.length > 0) {
const defaultTransition = currentStep.transitions[0];
if (defaultTransition.type === "url-path" || defaultTransition.type === "dom-click") {
return null;
}
const nextStepId = defaultTransition.nextStep;
const nextStep = store.manifest.steps.find((step) => step.id === nextStepId);
if (nextStep) {
return this.getVideoUrl(nextStep);
}
}
const currentIndex = store.manifest.steps.findIndex((step) => step.id === currentStep.id);
if (currentIndex >= 0 && currentIndex < store.manifest.steps.length - 1) {
return this.getVideoUrl(store.manifest.steps[currentIndex + 1]);
}
return null;
}
/**
* Gets the appropriate video URL for a step, preferring compressedVideoUrl if available
* @param step - The step to get the video URL for
* @returns The video URL to use (compressed if available, otherwise regular)
*/
getVideoUrl(step) {
return step.compressedVideoUrl || step.videoUrl;
}
/**
* Setup UI and Event updaters that subscribe to store changes
*/
setupUpdaters() {
if (this.uiUpdaterUnsubscribe) {
this.uiUpdaterUnsubscribe();
}
if (this.eventUpdaterUnsubscribe) {
this.eventUpdaterUnsubscribe();
}
const playerElement = this.uiManager.getPlayerElement();
if (playerElement) {
this.uiUpdaterUnsubscribe = setupUIUpdater(playerElement, this.cursorManager);
}
this.eventUpdaterUnsubscribe = setupEventUpdater(this.eventManager);
const stepTimeoutUnsubscribe = useSaltfishStore.subscribe(
(state) => {
this.stepTimeoutManager.update({
currentState: state.currentState,
currentStepId: state.currentStepId,
isMinimized: state.isMinimized,
previousState: void 0
// StepTimeoutManager doesn't need previous state
});
}
);
const originalEventUnsubscribe = this.eventUpdaterUnsubscribe;
this.eventUpdaterUnsubscribe = () => {
if (originalEventUnsubscribe) originalEventUnsubscribe();
stepTimeoutUnsubscribe();
};
}
/**
* Destroys the Saltfish playlist Player instance
*/
destroy() {
var _a;
if (!this.isInitialized) {
console.warn("Saltfish playlist Player is not initialized");
return;
}
try {
const store = useSaltfishStore.getState();
debug("SaltfishPlayer: Current state before destroying:", {
currentState: store.currentState,
currentStepId: store.currentStepId,
isMinimized: store.isMinimized,
manifestId: (_a = store.manifest) == null ? void 0 : _a.id
});
if (this.uiUpdaterUnsubscribe) {
this.uiUpdaterUnsubscribe();
this.uiUpdaterUnsubscribe = null;
}
if (this.eventUpdaterUnsubscribe) {
this.eventUpdaterUnsubscribe();
this.eventUpdaterUnsubscribe = null;
}
this.transitionManager.destroy();
this.triggerManager.destroy();
this.videoManager.destroy();
this.cursorManager.destroy();
this.interactionManager.destroy();
this.buttonManager.destroy();
this.analyticsManager.destroy();
this.sessionManager.destroy();
this.playlistManager.destroy();
this.stepTimeoutManager.destroy();
this.playerView.destroy();
this.dragManager.destroy();
this.uiManager.destroy();
this.isInitialized = false;
store.reset();
debug("SaltfishPlayer: Player destroyed successfully");
} catch (error2) {
ErrorHandler.handleCleanupError(
error2,
{
component: "SaltfishPlayer",
method: "destroy"
}
);
try {
debug("SaltfishPlayer: Attempting emergency cleanup after error");
this.isInitialized = false;
const store = useSaltfishStore.getState();
store.reset();
debug("SaltfishPlayer: Emergency cleanup completed");
} catch (cleanupError) {
ErrorHandler.handleCleanupError(
cleanupError,
{
component: "SaltfishPlayer",
method: "destroy",
additionalData: { originalError: error2 }
}
);
}
}
}
/**
* Handles completed state
*/
handleCompletedState() {
if (_SaltfishPlayer.destroyTimeoutId !== null) {
debug(`SaltfishPlayer: Found existing destroy timeout with ID: ${_SaltfishPlayer.destroyTimeoutId}, clearing it`);
window.clearTimeout(_SaltfishPlayer.destroyTimeoutId);
_SaltfishPlayer.destroyTimeoutId = null;
}
const store = useSaltfishStore.getState();
if (store.manifest) {
this.eventManager.trigger("playlistEnded", {
timestamp: Date.now(),
playlist: {
id: store.manifest.id,
title: store.manifest.name
}
});
}
this.cleanupPlaylist();
}
/**
* Registers an event listener to be called when the given eventName is triggered
* @param eventName - Name of event to listen for
* @param listener - Function to call when the event is triggered
*/
on(eventName, listener) {
this.eventManager.on(eventName, listener);
}
/**
* Removes an event listener for the given eventName
* @param eventName - Name of event to stop listening for
* @param listener - Function to remove
* @returns true if the listener was removed, false if it wasn't found
*/
off(eventName, listener) {
return this.eventManager.off(eventName, listener);
}
/**
* Resets the current playlist to its initial state
*/
resetPlaylist() {
const store = useSaltfishStore.getState();
if (store.manifest) {
store.goToStep(store.manifest.startStep);
}
}
/**
* Cleans up the current playlist state
*/
cleanupCurrentPlaylist() {
try {
resetEventUpdater();
if (this.stepTimeoutManager) {
this.stepTimeoutManager.reset();
}
if (this.videoManager) {
this.videoManager.pause();
}
if (this.cursorManager) {
this.cursorManager.stopAnimation();
this.cursorManager.setShouldShowCursor(false);
}
if (this.transitionManager) {
this.transitionManager.cleanupTransitions();
}
if (this.interactionManager) {
this.interactionManager.clearButtons();
this.interactionManager.clearDOMInteractions();
}
this.uiManager.resetDragState();
debug("SaltfishPlayer: Current playlist cleanup completed");
} catch (error2) {
ErrorHandler.handleCleanupError(
error2,
{
component: "SaltfishPlayer",
method: "cleanupCurrentPlaylist"
}
);
}
}
/**
* Cleans up playlist-specific resources while keeping trigger monitoring active
*/
cleanupPlaylist() {
var _a;
if (!this.isInitialized) {
console.warn("Saltfish playlist Player is not initialized");
return;
}
try {
const store = useSaltfishStore.getState();
debug("SaltfishPlayer: Current state before playlist cleanup:", {
currentState: store.currentState,
currentStepId: store.currentStepId,
isMinimized: store.isMinimized,
manifestId: (_a = store.manifest) == null ? void 0 : _a.id
});
if (this.uiUpdaterUnsubscribe) {
this.uiUpdaterUnsubscribe();
this.uiUpdaterUnsubscribe = null;
}
if (this.eventUpdaterUnsubscribe) {
this.eventUpdaterUnsubscribe();
this.eventUpdaterUnsubscribe = null;
}
this.transitionManager.destroy();
this.videoManager.destroy();
this.cursorManager.destroy();
this.interactionManager.destroy();
this.buttonManager.destroy();
this.playlistManager.destroy();
this.stepTimeoutManager.destroy();
this.playerView.destroy();
this.dragManager.destroy();
this.uiManager.destroy();
store.resetForNewPlaylist();
debug("SaltfishPlayer: Playlist cleanup completed, trigger monitoring preserved");
} catch (error2) {
ErrorHandler.handleCleanupError(
error2,
{
component: "SaltfishPlayer",
method: "cleanupPlaylist"
}
);
}
}
/**
* Updates the visibility of player controls based on the current state
* @param state - The current player state
*/
updateControlsVisibility(state) {
this.uiManager.updateControlsVisibility(state);
}
};
__publicField(_SaltfishPlayer, "instance", null);
__publicField(_SaltfishPlayer, "prevState", {
currentState: null,
currentStepId: null,
isMinimized: false
});
__publicField(_SaltfishPlayer, "destroyTimeoutId", null);
let SaltfishPlayer = _SaltfishPlayer;
const SaltfishPlayer$1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
__proto__: null,
SaltfishPlayer
}, Symbol.toStringTag, { value: "Module" }));
const mockManifest2 = {
id: "default_mock_playlist",
name: "Default playlist Demo",
version: "1.0.0",
position: "bottom-right",
startStep: "intro",
steps: [
{
id: "intro",
videoUrl: "https://storage.saltfish.ai/videos/QSWY4Wc8pcBVYHNQM12P.mp4",
transitions: [
{
type: "timeout",
timeout: 0,
nextStep: "dashboard_overview"
},
{
type: "dom-element-visible",
target: "body",
// Target the body element for visibility
nextStep: "dashboard_overview"
}
]
},
{
id: "dashboard_overview",
videoUrl: "https://storage.saltfish.ai/videos/rEWoNbKLAUQSvLmzHGmv.mp4",
position: "bottom-left",
domInteractions: [
{
selector: "#feature-chart",
action: "hover",
waitFor: false
}
],
buttons: [
{
id: "skip_button",
text: "Skip",
action: { type: "goto", target: "user_settings" }
}
],
transitions: []
},
{
id: "user_settings",
videoUrl: "https://storage.saltfish.ai/codeformer/oNcN4ZoQXFFnMmaOwEC3/result.mp4",
position: "top-right",
buttons: [
{
id: "finish_button",
text: "Finish Tour",
action: { type: "goto", target: "conclusion" }
},
{
id: "finish_two",
text: "Finish Tour",
action: { type: "goto", target: "conclusion" }
}
],
transitions: [
{
type: "dom-click",
target: "div:nth-of-type(3) > h2",
// CSS selector for the element that needs to be clicked
nextStep: "conclusion"
// The step to navigate to after the click
}
],
cursorAnimations: [
{
easing: "ease-out",
targetSelector: "div:nth-of-type(3) > h2"
}
]
},
{
id: "conclusion",
videoUrl: "https://storage.saltfish.ai/memo/5z5IDHYzuGDjx41hpvSo/result.mp4",
// Note: This step intentionally has no buttons to show the completion state
transitions: []
}
],
cursorColor: "#ff7614"
// Example color for testing
};
const mockManifest = {
id: "second_mock_playlist",
name: "Second Playlist Demo",
version: "1.0.0",
position: "bottom-right",
startStep: "step1",
steps: [
{
id: "step1",
videoUrl: "https://storage.saltfish.ai/videos/7e07f378-3059-4694-8659-8a4c1f04442d.mp4",
transcript: {
text: "Welcome to this demo video. We will show you how to use the transcript feature.",
segments: [
{
text: "Welcome to this demo video.",
start: 0,
end: 2.5
},
{
text: "We will show you how to use the transcript feature.",
start: 2.5,
end: 5
}
]
},
transitions: [
{
type: "dom-element-visible",
target: "#root > div > main > div.PreviewPage_footer__Ynnux > button",
// Target the body element for visibility
nextStep: "step2"
}
]
},
{
id: "step2",
videoUrl: "https://storage.saltfish.ai/videos/799b36f2-fc36-4864-8b6d-f8bf3d578140.mp4",
position: "bottom-left",
transitions: [
{
type: "dom-click",
target: "#root > div > main > div.PreviewPage_footer__Ynnux > button",
nextStep: "step3"
}
]
},
{
id: "step3",
videoUrl: "https://storage.saltfish.ai/videos/edb65857-e7c1-4edd-a4fc-67cf35880ac4.mp4",
buttons: [
{
id: "continue_button",
text: "Continue to Step 4",
action: { type: "goto", target: "step4" }
},
{
id: "skip_to_end",
text: "Skip to End",
action: { type: "goto", target: "step9" }
}
],
transitions: [
{
type: "timeout",
timeout: 0,
nextStep: "step4"
}
]
},
{
id: "step4",
videoUrl: "https://storage.saltfish.ai/videos/f25d8c5d-f7b6-48b1-ac39-4aae5124100a.mp4",
position: "center",
cursorAnimations: [
{
easing: "ease-out",
targetSelector: "#audience-chat-messages-panel-0 > div > div > div._container_1juas_1.MessageInput_container__KdCLm > div > button"
}
],
transitions: [
{
type: "dom-element-visible",
target: "#audience-chat-messages-panel-0 > div > div > div._container_vk4n0_1 > div.str-chat__virtual-list > div > div > div:nth-child(2) > div > div > div",
// Target the body element for visibility
nextStep: "step5"
}
]
},
{
id: "step5",
videoUrl: "https://storage.saltfish.ai/videos/e89abe5e-b83f-4ba5-8286-ee6e307bcf43.mp4",
cursorAnimations: [
{
easing: "ease-out",
targetSelector: "#audience-chat-messages-panel-0 > div > div > div._container_vk4n0_1 > div.str-chat__virtual-list > div > div > div:nth-child(2) > div > div > div > div._container_4oo5p_1 > div:nth-child(2) > button"
}
],
transitions: [
{
type: "dom-click",
target: "#root > div > main > div.PreviewPage_footer__Ynnux > button",
nextStep: "step6"
}
]
},
{
id: "step6",
videoUrl: "https://storage.saltfish.ai/videos/0ec59567-e865-4f9b-9ecd-7e4c5f2638a4.mp4",
transitions: [
{
type: "timeout",
timeout: 0,
nextStep: "step7"
}
]
},
{
id: "step7",
videoUrl: "https://storage.saltfish.ai/videos/38593171-53ac-426b-a6a8-0a3182c5947d.mp4",
position: "top-left",
cursorAnimations: [
{
easing: "ease-out",
targetSelector: "#root > div > main > section > div.SideButtons_container__jqcKh > div:nth-child(1)"
}
],
transitions: [
{
type: "dom-click",
target: "#root > div > main > section > div.SideButtons_container__jqcKh > div:nth-child(1)",
nextStep: "step3"
}
]
},
{
id: "step8",
videoUrl: "https://storage.saltfish.ai/videos/91441f25-2ffa-46dc-92fd-711be8512d27.mp4",
// Final step without any buttons or transitions
transitions: [
{
type: "timeout",
timeout: 0,
nextStep: "step9"
}
]
},
{
id: "step9",
videoUrl: "https://storage.saltfish.ai/videos/ecf26de5-c94c-4bef-b88c-6773420e01f4.mp4",
// Final step without any buttons or transitions
transitions: []
}
],
cursorColor: "#ff7614"
// Example color for testing
};
function createCustomMockManifest(overrides, useSecond = false) {
const baseManifest = useSecond ? mockManifest2 : mockManifest;
return {
...baseManifest,
...overrides,
steps: overrides.steps || baseManifest.steps
};
}
function createAPI() {
const player = SaltfishPlayer.getInstance();
let isInitializing = false;
let isInitialized = false;
let initPromise = null;
const commandQueue = [];
const processQueue = async () => {
if (commandQueue.length === 0) return;
while (commandQueue.length > 0) {
const command = commandQueue.shift();
if (command) {
try {
await command();
} catch (err) {
error("Error executing queued command:", err);
}
}
}
};
const api = {
init: (token) => {
if (isInitialized) {
info("Saltfish already initialized");
return Promise.resolve();
}
if (isInitializing && initPromise) {
return initPromise;
}
const config = typeof token === "string" ? { token } : token;
const fullConfig = {
sessionRecording: false,
enableAnalytics: true,
// Default to true
...config
};
info(`Saltfish initialized: analytics=${fullConfig.enableAnalytics}`);
isInitializing = true;
initPromise = player.initialize(fullConfig).then(() => {
isInitialized = true;
isInitializing = false;
return processQueue();
}).catch((error2) => {
isInitializing = false;
error2("Saltfish initialization failed:", error2);
throw error2;
});
return initPromise;
},
identify: (userId, userData) => {
if (!isInitialized && isInitializing) {
commandQueue.push(async () => {
player.identifyUser(userId, userData);
});
return;
}
player.identifyUser(userId, userData);
},
identifyAnonymous: (userData) => {
if (!isInitialized && isInitializing) {
commandQueue.push(async () => {
player.identifyAnonymous(userData);
});
return;
}
player.identifyAnonymous(userData);
},
startPlaylist: (playlistId, options) => {
if (!isInitialized && isInitializing) {
return new Promise((resolve, reject) => {
commandQueue.push(async () => {
try {
await player.startPlaylist(playlistId, options);
resolve();
} catch (error2) {
reject(error2);
}
});
if (initPromise) {
initPromise.catch(reject);
}
});
}
return player.startPlaylist(playlistId, options);
},
on: (eventName, listener) => {
player.on(eventName, listener);
},
off: (eventName, listener) => {
return player.off(eventName, listener);
},
resetPlaylist: () => {
if (!isInitialized && !isInitializing) {
warn("Cannot reset playlist - Saltfish not initialized");
return;
}
if (!isInitialized && isInitializing) {
commandQueue.push(async () => {
player.resetPlaylist();
});
return;
}
player.resetPlaylist();
},
destroy: () => {
if (!isInitialized && !isInitializing) {
warn("Cannot destroy - Saltfish not initialized");
return;
}
if (!isInitializing) {
isInitialized = false;
isInitializing = false;
initPromise = null;
commandQueue.length = 0;
}
player.destroy();
isInitialized = false;
isInitializing = false;
initPromise = null;
},
getSessionId: () => {
return player.getSessionId();
},
getRunId: () => {
return player.getRunId();
}
};
api.__dev__ = {
setMockManifest: (manifest) => {
if (typeof window !== "undefined") {
window.demoManifest = manifest;
}
},
createMockManifest: createCustomMockManifest,
defaultMockManifest: mockManifest,
getDeviceInfo: () => {
return DeviceDetector.getDeviceInfo();
}
};
return api;
}
const saltfish = createAPI();
if (typeof window !== "undefined") {
window.saltfish = saltfish;
}
export {
saltfish as default
};