@mottosports/motto-video-player
Version:
React video player component for the Motto platform, powered by Shaka Player
1 lines • 370 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","#style-inject:#style-inject","../src/styles.css","../src/Player.tsx","../src/hooks/useShakaPlayer.ts","../src/utils/devices.ts","../package.json","../src/utils/licenseCache.ts","../src/hooks/usePlayerInitialization.ts","../src/hooks/useEventHandlers.ts","../src/hooks/useAdEvents.ts","../src/hooks/useSystem73.ts","../src/hooks/useAdsInitialization.ts","../src/hooks/useMuxAnalytics.ts","../src/hooks/useQualityControl.ts","../src/hooks/useSkipControls.ts","../src/hooks/useKeyboardControls.ts","../src/hooks/useLiveIndicator.ts","../src/hooks/useShakaUI.ts","../src/icons/SkipBackIcon.tsx","../src/icons/SkipForwardIcon.tsx","../src/icons/BigPlayIcon.tsx","../src/utils/renderIcon.ts","../src/hooks/useScriptLoader.ts","../src/utils/scriptLoader.ts","../src/hooks/useDisableNativeControls.ts","../src/hooks/usePlayerStoreSync.ts","../src/store/PlayerStore.tsx","../src/hooks/usePosterFallback.ts","../src/hooks/useConcurrencySession.ts","../src/api/video.ts","../src/utils/recentPurchase.ts","../src/api/transport.ts","../src/utils/deviceId.ts","../src/utils/playlist.ts","../src/utils/playerStyles.ts","../src/components/Loading.tsx","../src/components/ErrorScreen.tsx","../src/components/Title.tsx","../src/components/TitleAndDescription.tsx","../src/messages/useMessages.tsx","../src/messages/en.json","../src/messages/es.json","../src/messages/ar.json","../src/messages/de.json","../src/messages/fr.json","../src/messages/it.json","../src/messages/pt.json","../src/messages/zh.json","../src/messages/nl.json","../src/messages/fa.json","../src/Video.tsx","../src/api/event.ts","../src/api/creative-work.ts","../src/helper.ts","../src/Event.tsx","../src/CreativeWork.tsx","../src/QueryProvider.tsx"],"sourcesContent":["// Import Shaka Player default control styles to ensure proper styling without needing external CDN links\nimport 'shaka-player/dist/controls.css';\n// Import library specific (Tailwind-based) overrides after the base Shaka styles so that our custom\n// styles take precedence.\nimport './styles.css';\nexport { Player } from './Player';\nexport { Video } from './Video';\nexport { Event } from './Event';\nexport { CreativeWork } from './CreativeWork';\nexport { QueryProvider, queryClient } from './QueryProvider';\nexport { SkipBackIcon, SkipForwardIcon, BigPlayIcon } from './icons';\nexport { PlayerProvider, usePlayerStore, usePlayerStoreApi } from './store/PlayerStore';\nexport type { PlayerProps, PlayerEvents, IconSizes, TimeUpdateData } from './types';\nexport type { PlayerStore, PlayerStoreState, PlayerStoreActions } from './store/PlayerStore';\nexport type { VideoProps } from './Video';\nexport type { EventProps } from './Event';\nexport type { CreativeWorkProps } from './CreativeWork';\nexport type { VideoData, ConcurrencySession, ConcurrencyServerMessage, ConcurrencyClientMessage } from './api/video';\nexport { ConcurrencyConnectionState } from './api/video';\nexport type { EventData, EventsSortDirection } from './api/event';\nexport type { CreativeWorkData, CreativeWorksSortDirection } from './api/creative-work';\n// Concurrency session hook types (for custom integrations)\nexport type { SessionStartedInfo, ConcurrencySessionState, UseConcurrencySessionOptions } from './hooks/useConcurrencySession';\nexport { useConcurrencySession } from './hooks/useConcurrencySession';\n// Device ID utility (for custom integrations)\nexport { getDeviceId, clearDeviceId } from './utils/deviceId'; ","\n export default function styleInject(css, { insertAt } = {}) {\n if (!css || typeof document === 'undefined') return\n \n const head = document.head || document.getElementsByTagName('head')[0]\n const style = document.createElement('style')\n style.type = 'text/css'\n \n if (insertAt === 'top') {\n if (head.firstChild) {\n head.insertBefore(style, head.firstChild)\n } else {\n head.appendChild(style)\n }\n } else {\n head.appendChild(style)\n }\n \n if (style.styleSheet) {\n style.styleSheet.cssText = css\n } else {\n style.appendChild(document.createTextNode(css))\n }\n }\n ","import styleInject from '#style-inject';styleInject(\"@layer components {\\n video::-webkit-media-controls {\\n display: none !important;\\n }\\n video::-webkit-media-controls-panel {\\n display: none !important;\\n }\\n video::-webkit-media-controls-play-button {\\n display: none !important;\\n }\\n video::-webkit-media-controls-timeline {\\n display: none !important;\\n }\\n video::-webkit-media-controls-current-time-display {\\n display: none !important;\\n }\\n video::-webkit-media-controls-time-remaining-display {\\n display: none !important;\\n }\\n video::-webkit-media-controls-mute-button {\\n display: none !important;\\n }\\n video::-webkit-media-controls-volume-slider {\\n display: none !important;\\n }\\n video::-webkit-media-controls-fullscreen-button {\\n display: none !important;\\n }\\n video::-webkit-media-controls-overlay-play-button {\\n display: none !important;\\n }\\n video::-moz-media-controls {\\n display: none !important;\\n }\\n video {\\n outline: none !important;\\n }\\n video[controls] {\\n -webkit-appearance: none !important;\\n appearance: none !important;\\n }\\n video::-webkit-media-controls-enclosure {\\n display: none !important;\\n }\\n video::-webkit-media-controls-start-playback-button {\\n display: none !important;\\n }\\n video[controls]::-webkit-media-controls,\\n video[controls]::-webkit-media-controls-panel,\\n video[controls]::-webkit-media-controls-play-button,\\n video[controls]::-webkit-media-controls-timeline,\\n video[controls]::-webkit-media-controls-current-time-display,\\n video[controls]::-webkit-media-controls-time-remaining-display,\\n video[controls]::-webkit-media-controls-mute-button,\\n video[controls]::-webkit-media-controls-volume-slider,\\n video[controls]::-webkit-media-controls-fullscreen-button,\\n video[controls]::-webkit-media-controls-overlay-play-button,\\n video[controls]::-webkit-media-controls-enclosure,\\n video[controls]::-webkit-media-controls-start-playback-button {\\n display: none !important;\\n visibility: hidden !important;\\n opacity: 0 !important;\\n pointer-events: none !important;\\n }\\n video[controls]::-moz-media-controls {\\n display: none !important;\\n visibility: hidden !important;\\n opacity: 0 !important;\\n }\\n .motto-video-container {\\n @apply relative w-full;\\n min-height: 300px;\\n }\\n @supports (aspect-ratio: 16/9) {\\n .motto-video-container {\\n min-height: auto;\\n }\\n }\\n .motto-video-responsive {\\n @apply absolute top-0 left-0 w-full h-full;\\n }\\n .motto-skip-button {\\n @apply absolute top-1/2 -translate-y-1/2 bg-black/70 text-white border-0 rounded-full w-16 h-16 text-2xl cursor-pointer flex items-center justify-center transition-all duration-200 z-10 opacity-80 hover:opacity-100 hover:scale-110 active:scale-95;\\n }\\n .motto-skip-button-back {\\n @apply left-5;\\n }\\n .motto-skip-button-forward {\\n @apply right-5;\\n }\\n}\\n.shaka-seek-bar-container {\\n height: 6px !important;\\n width: 100% !important;\\n margin: 8px 0 !important;\\n border-radius: 4px !important;\\n position: relative !important;\\n border-top: none !important;\\n border-bottom: none !important;\\n box-shadow: none !important;\\n}\\n.shaka-seek-bar {\\n height: 6px !important;\\n width: 100% !important;\\n -webkit-appearance: none !important;\\n appearance: none !important;\\n background: transparent !important;\\n cursor: pointer !important;\\n border: none !important;\\n outline: none !important;\\n position: absolute !important;\\n top: 0 !important;\\n left: 0 !important;\\n border-radius: 4px !important;\\n}\\n.shaka-seek-bar::-webkit-slider-runnable-track {\\n height: 6px !important;\\n background: transparent !important;\\n border-radius: 4px !important;\\n border: none !important;\\n}\\n.shaka-seek-bar::-moz-range-track {\\n height: 6px !important;\\n background: transparent !important;\\n border-radius: 4px !important;\\n border: none !important;\\n}\\n.shaka-seek-bar::-webkit-slider-thumb {\\n -webkit-appearance: none !important;\\n appearance: none !important;\\n width: 16px !important;\\n height: 16px !important;\\n border-radius: 50% !important;\\n background: #ffffff !important;\\n cursor: pointer !important;\\n border: 2px solid #ffffff !important;\\n box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important;\\n margin-top: -4px !important;\\n}\\n.shaka-seek-bar::-moz-range-thumb {\\n width: 16px !important;\\n height: 16px !important;\\n border-radius: 50% !important;\\n background: #ffffff !important;\\n cursor: pointer !important;\\n border: 2px solid #ffffff !important;\\n box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important;\\n margin-top: -4px !important;\\n}\\n.motto-skip-back-button,\\n.motto-skip-forward-button,\\n.motto-native-skip-button {\\n background: transparent !important;\\n border: none !important;\\n padding: 4px !important;\\n margin: 0px !important;\\n cursor: pointer !important;\\n color: #ffffff !important;\\n transition: all 0.2s ease !important;\\n min-width: 32px !important;\\n height: 32px !important;\\n display: flex !important;\\n align-items: center !important;\\n justify-content: center !important;\\n border-radius: 4px !important;\\n width: 25px;\\n}\\n.motto-skip-back-button:hover,\\n.motto-skip-forward-button:hover,\\n.motto-native-skip-button:hover {\\n opacity: 0.8 !important;\\n background: transparent !important;\\n transform: scale(1.05) !important;\\n}\\n.motto-skip-back-button:active,\\n.motto-skip-forward-button:active,\\n.motto-native-skip-button:active {\\n transform: scale(0.95) !important;\\n}\\n.motto-skip-back-button svg,\\n.motto-skip-forward-button svg,\\n.motto-native-skip-button svg {\\n width: 24px !important;\\n height: 24px !important;\\n}\\n.shaka-spinner-svg {\\n color: white !important;\\n fill: white !important;\\n}\\n.shaka-spinner-path {\\n stroke: white !important;\\n fill: none !important;\\n}\\n.shaka-spinner-container {\\n color: white !important;\\n}\\n.shaka-buffering-spinner {\\n color: white !important;\\n fill: white !important;\\n}\\n.shaka-buffering-spinner svg {\\n color: white !important;\\n fill: white !important;\\n}\\n.shaka-buffering-spinner path {\\n stroke: white !important;\\n fill: none !important;\\n}\\n[data-shaka-player-container] .shaka-spinner,\\n[data-shaka-player-container] .spinner {\\n color: white !important;\\n border-color: white !important;\\n}\\n.material-icons.shaka-spinner {\\n color: white !important;\\n}\\n.shaka-controls-container .shaka-spinner,\\n.shaka-video-container .shaka-spinner {\\n color: white !important;\\n fill: white !important;\\n}\\n.shaka-controls-container .shaka-spinner svg,\\n.shaka-video-container .shaka-spinner svg {\\n color: white !important;\\n fill: white !important;\\n}\\n.shaka-controls-container .shaka-spinner path,\\n.shaka-video-container .shaka-spinner path {\\n stroke: white !important;\\n}\\n.motto-video-loading-overlay {\\n position: absolute;\\n top: 0;\\n left: 0;\\n width: 100%;\\n height: 100%;\\n background:\\n linear-gradient(\\n 135deg,\\n #1a1a1a 0%,\\n #2d2d2d 100%);\\n display: flex;\\n flex-direction: column;\\n align-items: center;\\n justify-content: center;\\n z-index: 10;\\n transition: opacity 0.3s ease;\\n}\\n.motto-video-loading-overlay.hidden {\\n opacity: 0;\\n pointer-events: none;\\n}\\n.motto-video-loading-content {\\n text-align: center;\\n color: white;\\n}\\n.motto-video-loading-icon {\\n width: 64px;\\n height: 64px;\\n margin-bottom: 16px;\\n opacity: 0.7;\\n}\\n.motto-video-loading-text {\\n font-size: 16px;\\n font-weight: 500;\\n margin-bottom: 8px;\\n}\\n.motto-video-loading-subtext {\\n font-size: 14px;\\n opacity: 0.7;\\n}\\n@keyframes pulse-live {\\n 0% {\\n opacity: 1;\\n transform: scale(1);\\n }\\n 50% {\\n opacity: 0.7;\\n transform: scale(1.1);\\n }\\n 100% {\\n opacity: 1;\\n transform: scale(1);\\n }\\n}\\n.shaka-play-button {\\n background: rgba(255, 255, 255, 0.1) !important;\\n border: none !important;\\n color: white !important;\\n padding: 10px !important;\\n border-radius: 100% !important;\\n transition: all 0.2s ease !important;\\n display: flex !important;\\n align-items: center !important;\\n justify-content: center !important;\\n min-width: 55px !important;\\n height: 55px !important;\\n}\\n.shaka-play-button-container {\\n background: transparent;\\n transition: all 0.2s ease !important;\\n}\\n.motto-video-container:not(.no-cursor) .shaka-play-button-container {\\n background: rgba(0, 0, 0, 0.3);\\n transition: all 0.s ease !important;\\n}\\n.shaka-play-button:hover {\\n background: rgba(255, 255, 255, 0.2) !important;\\n transform: scale(1.05) !important;\\n}\\n.shaka-play-button:active {\\n transform: scale(0.95) !important;\\n}\\n.shaka-play-button > * {\\n display: none !important;\\n}\\n.shaka-play-button::after {\\n content: \\\"\\\" !important;\\n width: 35px !important;\\n height: 35px !important;\\n background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 24 24\\\" fill=\\\"white\\\"><path fill-rule=\\\"evenodd\\\" d=\\\"M4.5 5.653c0-1.427 1.529-2.33 2.779-1.643l11.54 6.347c1.295.712 1.295 2.573 0 3.286L7.28 19.99c-1.25.687-2.779-.217-2.779-1.643V5.653Z\\\" clip-rule=\\\"evenodd\\\" /></svg>') !important;\\n background-repeat: no-repeat !important;\\n background-size: contain !important;\\n background-position: center !important;\\n display: block !important;\\n}\\n.shaka-play-button[aria-label*=Play]::after {\\n background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 24 24\\\" fill=\\\"white\\\"><path fill-rule=\\\"evenodd\\\" d=\\\"M4.5 5.653c0-1.427 1.529-2.33 2.779-1.643l11.54 6.347c1.295.712 1.295 2.573 0 3.286L7.28 19.99c-1.25.687-2.779-.217-2.779-1.643V5.653Z\\\" clip-rule=\\\"evenodd\\\" /></svg>') !important;\\n}\\n.shaka-play-button[aria-label*=Pause]::after {\\n background-image: url('data:image/svg+xml;charset=utf-8,<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 24 24\\\" fill=\\\"white\\\"><path fill-rule=\\\"evenodd\\\" d=\\\"M6.75 5.25a.75.75 0 0 1 .75-.75H9a.75.75 0 0 1 .75.75v13.5a.75.75 0 0 1-.75.75H7.5a.75.75 0 0 1-.75-.75V5.25Zm7.5 0A.75.75 0 0 1 15 4.5h1.5a.75.75 0 0 1 .75.75v13.5a.75.75 0 0 1-.75.75H15a.75.75 0 0 1-.75-.75V5.25Z\\\" clip-rule=\\\"evenodd\\\" /></svg>') !important;\\n}\\n.motto-video-container {\\n background: #111111;\\n}\\n.motto-video-container video {\\n width: 100% !important;\\n height: 100% !important;\\n margin-left: auto !important;\\n margin-right: auto !important;\\n}\\nhtml[dir=rtl] .shaka-controls-container,\\nhtml[dir=rtl] .shaka-bottom-controls,\\nhtml[dir=rtl] .shaka-controls-button-panel {\\n direction: ltr !important;\\n}\\nhtml[dir=rtl] .shaka-overflow-menu,\\nhtml[dir=rtl] .shaka-settings-menu {\\n direction: rtl !important;\\n text-align: right !important;\\n}\\nhtml[dir=rtl] .shaka-overflow-menu .shaka-overflow-button .material-svg-icon:not(:first-child) {\\n margin-right: 12px !important;\\n margin-left: 0 !important;\\n}\\nhtml[dir=rtl] .shaka-overflow-menu .shaka-overflow-button .material-svg-icon:first-child {\\n margin-right: 0 !important;\\n margin-left: 12px !important;\\n}\\n.shaka-hidden-fast-forward-container,\\n.shaka-hidden-rewind-container {\\n pointer-events: none !important;\\n display: none !important;\\n}\\n\")","import { forwardRef, useCallback, useEffect, useRef, useImperativeHandle, useState } from 'react';\nimport { PlayerProps } from './types';\nimport {\n useShakaPlayer,\n useQualityControl,\n useSkipControls,\n useMuxAnalytics,\n useShakaUI,\n useEventHandlers,\n useLiveIndicator,\n useKeyboardControls,\n useAdEvents,\n usePlayerStoreSync,\n usePlayerInitialization,\n useDisableNativeControls,\n useScriptLoader,\n useSystem73,\n useAdsInitialization\n} from './hooks';\nimport './styles.css';\nimport { hasPlaylistUrl } from './utils/playlist';\nimport { getPlayerStyles } from './utils/playerStyles';\nimport { Loading, ErrorScreen } from './components';\nimport { BigPlayIcon } from './icons';\nimport useMessages, { type Translations } from './messages/useMessages';\n\n// Declare google namespace for IMA SDK\ndeclare global {\n namespace google {\n namespace ima {\n class AdsRequest {\n adTagUrl: string;\n }\n }\n }\n}\n\nexport const Player = forwardRef<HTMLVideoElement, PlayerProps>(\n ({ \n src, \n managedMode = false,\n autoPlay = false, \n loop = false, \n muted = false, \n controls = true,\n poster,\n width,\n height,\n aspectRatio = 16/9,\n shakaConfig,\n drmConfig,\n muxConfig,\n system73Config,\n imaConfig,\n chromecastConfig,\n qualityConfig,\n seekbarConfig,\n iconSizes,\n events,\n locale = 'en',\n containerClassName,\n liveThresholdSeconds = 15,\n publicKey,\n auth,\n ...videoProps\n }, ref) => {\n const videoRef = useRef<HTMLVideoElement>(null);\n const containerRef = useRef<HTMLDivElement>(null);\n const [isInitialLoading, setIsInitialLoading] = useState(true);\n const [hasStartedPlaying, setHasStartedPlaying] = useState(false);\n const [bfResetKey, setBfResetKey] = useState(0);\n // Translation key of a playback-blocking segment error (X-Error-Code) to\n // display over the player; null while playback is allowed.\n const [blockedMessageKey, setBlockedMessageKey] = useState<string | null>(null);\n // Playback died with no more specific screen to show (e.g. a failed\n // load); shows the default error screen instead of an endless spinner.\n const [hasFatalError, setHasFatalError] = useState(false);\n const { t } = useMessages(locale);\n\n const handlePlaybackBlocked = useCallback((messageKey: string) => {\n setBlockedMessageKey(messageKey);\n events?.onError?.(new Error(messageKey));\n }, [events]);\n\n const handleFatalError = useCallback(() => {\n setHasFatalError(true);\n }, []);\n\n // Load external scripts (IMA SDK, System73)\n const isScriptsLoaded = useScriptLoader({\n hasAds: !!imaConfig?.adTagUrl,\n hasSystem73: !!system73Config?.apiKey\n });\n\n // Managed mode optimization: prevents unnecessary reinitialization\n // When wrapper components (Video, Event, CreativeWork) refetch data, the src object\n // reference changes even if the playlist URL is the same. In managedMode, we only\n // reinitialize when hasPlaylist changes (boolean: playlist exists or doesn't exist),\n // rather than on every src object reference change.\n const hasPlaylist = hasPlaylistUrl(src);\n\n // Core Shaka Player hook - provides low-level player management\n // Returns methods for initialization, manifest loading, and cleanup\n // IMPORTANT: hasAds and hasSystem73 flags control automatic manifest loading\n // - When true: initializePlayer() skips auto-load, allowing System73/ads to initialize first\n // - When false: initializePlayer() loads manifest automatically\n // See ARCHITECTURE.md for detailed initialization sequence\n const { playerRef, initializePlayer, loadManifest, destroyPlayer, handleMuxError } = useShakaPlayer({\n src,\n shakaConfig,\n drmConfig,\n onError: events?.onError,\n onPlaybackBlocked: handlePlaybackBlocked,\n onFatalError: handleFatalError,\n onPlayerReady: events?.onPlayerReady,\n muxConfig,\n onMuxReady: events?.onMuxReady,\n onMuxDataUpdate: events?.onMuxDataUpdate,\n publicKey,\n mottoToken: auth?.mottoToken,\n hasAds: !!imaConfig?.adTagUrl, // Skip auto-load if ads configured\n hasSystem73: !!system73Config?.apiKey, // Skip auto-load if System73 configured\n apiToken: auth?.apiToken\n });\n\n const {\n updateMuxData,\n destroyMux\n } = useMuxAnalytics(playerRef, events?.onMuxDataUpdate);\n\n // Forward reference pattern to break circular hook dependency:\n // - useQualityControl needs to call syncQualityChange in its callback\n // - usePlayerStoreSync (which provides syncQualityChange) needs setQuality from useQualityControl\n // Solution: Create ref now, populate it after usePlayerStoreSync, callback uses ref\n const syncQualityChangeRef = useRef<((quality: { height: number; bandwidth: number }) => void) | null>(null);\n\n const {\n getAvailableQualities,\n setQuality,\n setupQualityTracking,\n configureQuality\n } = useQualityControl(playerRef, qualityConfig, (quality) => {\n // Sync to store via ref (populated below after usePlayerStoreSync)\n syncQualityChangeRef.current?.(quality);\n // Call user's onQualityChange callback\n events?.onQualityChange?.(quality);\n });\n\n // Skip controls - core logic for jumping forward/backward in video\n // Separated from input binding to allow multiple input methods (keyboard, UI buttons, store, API)\n const { \n skipBack, \n skipForward, \n } = useSkipControls(videoRef, events?.onSkipBack, events?.onSkipForward);\n\n // Keyboard input binding - maps arrow keys to skip functions\n useKeyboardControls(videoRef, {\n skipBack,\n skipForward,\n enabled: true\n });\n\n const { setupEventListeners, cleanupEventListeners } = useEventHandlers(videoRef, playerRef, events ?? {});\n\n const { uiRef, initializeUI, destroyUI } = useShakaUI(\n playerRef,\n containerRef,\n videoRef,\n controls,\n chromecastConfig,\n seekbarConfig,\n events?.onSkipBack,\n events?.onSkipForward,\n iconSizes,\n locale\n );\n\n const { getLiveStatus, seekToLiveEdge } = useLiveIndicator(containerRef, playerRef, {\n enabled: true,\n indicatorColor: '#ff0000',\n indicatorSize: 8,\n showPulseAnimation: true,\n liveThresholdSeconds,\n onLiveStatusChange: events?.onLiveStatusChange\n });\n\n const { setupAdEventListeners, cleanupAdEventListeners } = useAdEvents(playerRef, events ?? {});\n\n // Store synchronization (only active when wrapped in PlayerProvider)\n const { syncQualityChange, updateAvailableQualities } = usePlayerStoreSync({\n videoRef,\n playerRef,\n getLiveStatus,\n skipForward,\n skipBack,\n setQuality,\n getAvailableQualities,\n seekToLiveEdge,\n });\n\n // Populate the ref to complete the forward reference pattern (see syncQualityChangeRef above)\n syncQualityChangeRef.current = syncQualityChange;\n\n const { initializeSystem73 } = useSystem73(system73Config);\n\n const { initializeAds } = useAdsInitialization({\n imaConfig,\n autoPlay,\n playerRef,\n videoRef,\n uiRef,\n setupAdEventListeners,\n loadManifest\n });\n\n // Reinitialize on back/forward cache restore without double-initializing on first load\n useEffect(() => {\n const onPageShow = (e: any) => {\n if (e && e.persisted) {\n setBfResetKey((k) => k + 1);\n }\n };\n window.addEventListener('pageshow', onPageShow);\n return () => window.removeEventListener('pageshow', onPageShow);\n }, []);\n\n // Main initialization - orchestrates all player setup in the correct sequence\n // See usePlayerInitialization.ts for detailed documentation on initialization order\n usePlayerInitialization({\n videoRef,\n playerRef,\n isScriptsLoaded,\n setIsInitialLoading,\n system73Config,\n shakaConfig,\n imaConfig,\n initializeSystem73,\n initializePlayer,\n loadManifest,\n setupEventListeners,\n setupQualityTracking,\n configureQuality,\n updateAvailableQualities,\n initializeUI,\n initializeAds,\n handleMuxError,\n cleanupEventListeners,\n cleanupAdEventListeners,\n destroyUI,\n destroyMux,\n destroyPlayer,\n src,\n hasPlaylist,\n managedMode,\n bfResetKey,\n });\n\n // Update video properties when they change\n useEffect(() => {\n const video = videoRef.current;\n if (!video) return;\n\n video.autoplay = autoPlay;\n video.loop = loop;\n if (poster) video.poster = poster;\n }, [autoPlay, loop, muted, poster, imaConfig?.adTagUrl]);\n\n // Track first play so poster overlay hides when playback starts\n useEffect(() => {\n const video = videoRef.current;\n if (!video || !poster) return;\n const onPlay = () => setHasStartedPlaying(true);\n video.addEventListener('play', onPlay);\n return () => video.removeEventListener('play', onPlay);\n }, [poster]);\n\n // Reset poster overlay and any blocking error when src changes\n useEffect(() => {\n setHasStartedPlaying(false);\n setBlockedMessageKey(null);\n setHasFatalError(false);\n }, [src]);\n\n // Prevent native controls from conflicting with Shaka UI\n useDisableNativeControls(videoRef);\n\n // Expose methods via ref\n useImperativeHandle(ref, () => ({\n ...videoRef.current!,\n // Custom methods for quality control\n getAvailableQualities,\n setQuality,\n // Skip methods\n skipBack,\n skipForward,\n // Mux methods\n updateMuxData,\n // Live status methods\n getLiveStatus,\n seekToLiveEdge,\n // Access to underlying instances\n getPlayer: () => playerRef.current,\n getMuxMonitor: () => null\n }), [getAvailableQualities, setQuality, skipBack, skipForward, updateMuxData, getLiveStatus, seekToLiveEdge]);\n\n const { isResponsive, containerClasses, containerStyle, videoClasses, videoStyle } = getPlayerStyles({\n width,\n height,\n aspectRatio,\n containerClassName\n });\n\n // Filter out controls prop to prevent conflicts with native HTML5 controls\n const filteredVideoProps = { ...videoProps };\n delete (filteredVideoProps as any).controls;\n\n return (\n <div \n ref={containerRef}\n className={containerClasses}\n style={containerStyle}\n >\n <video\n ref={videoRef}\n className={videoClasses}\n width={isResponsive ? undefined : width}\n height={isResponsive ? undefined : height}\n style={videoStyle}\n controls={false}\n playsInline={true}\n {...filteredVideoProps}\n />\n\n {poster && !hasStartedPlaying && (\n <div className=\"absolute inset-0\" style={{ zIndex: 10 }}>\n <img src={poster} alt=\"\" className=\"w-full h-full object-cover\" />\n <button\n className=\"absolute inset-0 flex items-center justify-center w-full h-full bg-transparent border-0 cursor-pointer\"\n onClick={() => videoRef.current?.play()}\n aria-label=\"Play\"\n >\n <div className=\"flex items-center justify-center w-16 h-16 rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors\">\n <BigPlayIcon size={32} />\n </div>\n </button>\n </div>\n )}\n\n {isInitialLoading && !blockedMessageKey && !hasFatalError && (\n <div className=\"absolute inset-0 flex items-center justify-center z-20 pointer-events-none\">\n <Loading className=\"bg-transparent\" />\n </div>\n )}\n\n {blockedMessageKey && (\n <div className=\"absolute inset-0 z-30\">\n <ErrorScreen\n title={t(blockedMessageKey as keyof Translations)}\n description={t(`${blockedMessageKey}_DESCRIPTION` as keyof Translations)}\n />\n </div>\n )}\n\n {hasFatalError && !blockedMessageKey && (\n <div className=\"absolute inset-0 z-30\">\n <ErrorScreen\n title={t('DEFAULT_ERROR')}\n description={t('DEFAULT_ERROR_DESCRIPTION')}\n />\n </div>\n )}\n </div>\n );\n }\n);\n\nPlayer.displayName = 'Player'; ","import { useRef, useCallback, useState } from 'react';\nimport shaka from 'shaka-player/dist/shaka-player.ui';\nimport { isAppleDevice, isPlayReadySupported, supportsWidevinePersistentLicenses } from '../utils/devices';\nimport { Playlist } from '../api';\nimport { MuxAnalyticsConfig } from '../types';\nimport initShakaPlayerMux from '@mux/mux-data-shakaplayer';\nimport { version } from '../../package.json';\nimport {\n PersistentSessionMetadata,\n storePersistentLicense,\n retrievePersistentLicense,\n clearAllPersistentLicenses\n} from '../utils/licenseCache';\n\n/**\n * Removes the query string (and hash) from a URL, dropping signed CDN tokens\n * before the URL is reported to analytics. Returns the input unchanged if it\n * cannot be parsed.\n */\nconst stripUrlQuery = (url: string): string => {\n if (!url) return url;\n const queryIndex = url.search(/[?#]/);\n return queryIndex === -1 ? url : url.slice(0, queryIndex);\n};\n\n/**\n * Wraps a Shaka player in a Proxy that scrubs signed CDN tokens from the two\n * URL sources Mux reads — getAssetUri() (video_source_url) and each\n * response.uri (request_url/request_hostname) — WITHOUT touching the real\n * player. The signature must stay on every URL Shaka uses; stripping the real\n * response.uri made Shaka refresh the live media playlist unsigned (HTTP 500).\n * Only the Mux-facing view is scrubbed:\n * - getAssetUri() returns the de-tokenized load URL.\n * - the networking engine's registerResponseFilter wraps Mux's filter so it\n * receives a shallow clone of each response with a stripped uri, leaving the\n * real response object (and Shaka's playback) untouched.\n */\nconst createMuxScrubbingPlayer = (player: any): any =>\n new Proxy(player, {\n get(target, prop) {\n if (prop === 'getAssetUri') {\n return () => stripUrlQuery(target.getAssetUri?.() ?? '');\n }\n if (prop === 'getNetworkingEngine') {\n return () => {\n const engine = target.getNetworkingEngine();\n if (!engine) return engine;\n return new Proxy(engine, {\n get(engineTarget, engineProp) {\n if (engineProp === 'registerResponseFilter') {\n return (filter: any) =>\n engineTarget.registerResponseFilter(\n (type: any, response: any, ...rest: any[]) =>\n filter(\n type,\n { ...response, uri: stripUrlQuery(response?.uri) },\n ...rest\n )\n );\n }\n const value = engineTarget[engineProp];\n return typeof value === 'function' ? value.bind(engineTarget) : value;\n },\n });\n };\n }\n // Return `constructor` unbound: Mux validates the player via\n // `player.constructor.version`, and a bound function loses the original's\n // static `version` property — which made initShakaPlayerMux reject the\n // proxy (\"You must provide a valid shakaPlayer\") and skip all analytics.\n const value = target[prop];\n if (typeof value !== 'function' || prop === 'constructor') return value;\n return value.bind(target);\n },\n });\n\n/**\n * Manages the Shaka Player lifecycle, configuration, and low-level operations.\n * \n * RESPONSIBILITIES:\n * ----------------\n * - Create/destroy Shaka Player instances with proper lifecycle coordination\n * - Configure DRM systems (Widevine, PlayReady, FairPlay) with platform-specific settings\n * - Handle persistent license caching and stale license recovery\n * - Setup network filters for authentication headers and FairPlay base64 decoding\n * - Error handling with automatic retry logic for DRM failures\n * - Mux Analytics integration for monitoring and debugging\n * \n * IMPORTANT: Conditional Manifest Loading\n * ---------------------------------------\n * This hook skips automatic manifest loading when hasAds=true or hasSystem73=true.\n * This is critical because:\n * \n * - System73 SDK must wrap the player BEFORE manifest loads to detect all ABR profiles\n * - Google IMA ads need to intercept loading to serve pre-roll ads\n * \n * When skipped, the caller (typically usePlayerInitialization) must explicitly call\n * loadManifest() after the third-party integrations are ready.\n * \n * RELATIONSHIP TO OTHER HOOKS:\n * ---------------------------\n * This hook provides the \"HOW\" - it knows how to configure and manage Shaka Player.\n * It does NOT control \"WHEN\" - that's handled by usePlayerInitialization, which\n * orchestrates the initialization sequence across all integrations.\n * \n * EXPORTED METHODS:\n * ----------------\n * - initializePlayer(video): Creates and configures player, but may skip manifest load\n * - loadManifest(): Explicitly loads the manifest (called after System73/ads setup)\n * - destroyPlayer(): Cleans up player instance, listeners, and resources\n * \n * REFS & STATE:\n * ------------\n * - playerRef: Reference to Shaka Player instance\n * - videoElementRef: Reference to HTMLVideoElement for retry functionality\n * - isRetrying: State flag to prevent multiple simultaneous retry attempts\n * - Multiple DRM-related refs for persistent license management and cleanup\n */\n\ninterface DRMConfig {\n clearKeys?: { [keyId: string]: string };\n servers?: { [keySystem: string]: string };\n}\n\n// Maps the CDN's X-Error-Code segment response header to the translation key\n// shown on the player's blocking error screen. 940 (playback token expired,\n// e.g. after a long pause) is handled separately by reloading the page so a\n// fresh token is issued.\nconst SEGMENT_ERROR_MESSAGE_KEYS: Record<string, string> = {\n '965': 'ERROR_CODE_IP_CHANGED',\n '970': 'ERROR_CODE_VPN_DETECTED',\n '971': 'ERROR_CODE_ANONYMOUS_PROXY',\n};\n\ninterface UseShakaPlayerProps {\n src: Playlist | string;\n shakaConfig?: any;\n drmConfig?: DRMConfig;\n onError?: (error: Error) => void;\n /** Fired when a segment response carries an X-Error-Code that blocks\n * playback (IP change, VPN, anonymous proxy). Receives the translation key\n * of the message to display. */\n onPlaybackBlocked?: (messageKey: string) => void;\n /** Fired when playback cannot continue and no more specific handling\n * applies (blocked segments go through onPlaybackBlocked instead), so the\n * player can show a default error screen rather than load forever. */\n onFatalError?: () => void;\n onPlayerReady?: (player: any) => void;\n muxConfig?: MuxAnalyticsConfig;\n onMuxReady?: () => void;\n onMuxDataUpdate?: (data: any) => void;\n publicKey?: string;\n mottoToken?: string;\n hasAds?: boolean;\n hasSystem73?: boolean;\n apiToken?: string;\n}\n\n// Helper to normalize src to a Playlist-like object\nconst normalizeSource = (src: Playlist | string): Playlist => {\n if (typeof src === 'string') {\n return {\n id: '',\n url: src,\n format: 'auto',\n drm: {}\n } as Playlist;\n }\n return src;\n};\n\nexport const useShakaPlayer = ({\n src,\n shakaConfig,\n drmConfig,\n onError,\n onPlaybackBlocked,\n onFatalError,\n onPlayerReady,\n muxConfig,\n onMuxReady,\n onMuxDataUpdate,\n publicKey,\n mottoToken,\n hasAds = false,\n hasSystem73 = false,\n apiToken\n}: UseShakaPlayerProps) => {\n const playerRef = useRef<any>(null);\n const [isRetrying, setIsRetrying] = useState(false);\n const videoElementRef = useRef<HTMLVideoElement | null>(null);\n\n // Monitor returned by initShakaPlayerMux. Needed for loadErrorHandler:\n // errors thrown by player.load() reject the promise and never fire Shaka's\n // 'error' event, so automaticErrorTracking cannot see them.\n const muxMonitorRef = useRef<any>(null);\n\n // Lifecycle coordination to prevent init/destroy races\n const destroyInProgressRef = useRef<Promise<void> | null>(null);\n const isDestroyingRef = useRef<boolean>(false);\n const initSequenceRef = useRef<number>(0);\n const activeInitIdRef = useRef<number | null>(null);\n\n /* DRM related state */\n const waitingForKeyTimerRef = useRef<number | null>(null);\n const waitingForKeyHandlerRef = useRef<((e: Event) => void) | null>(null);\n const playbackResumedHandlerRef = useRef<((e: Event) => void) | null>(null);\n const usingPersistentLicenseRef = useRef<boolean>(false);\n const storedPersistentThisLoadRef = useRef<boolean>(false);\n const drmExpirationHandlerRef = useRef<((e: Event) => void) | null>(null);\n\n // Ensures an X-Error-Code on a segment response is acted on only once per\n // load: segment requests keep coming (and retrying) while blocked, so\n // without this guard we would reload the page or re-raise the error screen\n // repeatedly.\n const segmentErrorHandledRef = useRef<boolean>(false);\n\n // Helper function to determine the correct manifest URL based on DRM support\n const getManifestUrl = useCallback(() => {\n const playlistSrc = normalizeSource(src);\n let manifestUrl = playlistSrc.url;\n const isDRM = Boolean(playlistSrc.drm?.widevine || playlistSrc.drm?.fairplay || playlistSrc.drm?.playready);\n\n if (isDRM) {\n const isPlayReady = isPlayReadySupported();\n if(isAppleDevice() && playlistSrc.drm?.fairplay?.certificateUrl) {\n manifestUrl = playlistSrc.drm.fairplay.playlistUrl;\n } else if(isPlayReady && playlistSrc.drm?.playready?.licenseUrl) {\n manifestUrl = playlistSrc.drm.playready.playlistUrl;\n } else {\n manifestUrl = playlistSrc.drm?.widevine?.playlistUrl || \"\";\n }\n }\n\n return manifestUrl;\n }, [src]);\n\n // Acts on the X-Error-Code header of a segment response. Returns true when\n // the code was recognized and handled (or already handled this load).\n // The CDN also writes the code into the plain-text body of error responses\n // (\"error 971\"); responseBody is the fallback for when CORS hides the\n // header (Access-Control-Expose-Headers missing on the error response).\n const handleSegmentErrorCode = useCallback((errorCode: unknown, responseBody?: unknown): boolean => {\n let code = errorCode === undefined || errorCode === null ? '' : String(errorCode).trim();\n if (!code && typeof responseBody === 'string') {\n code = /^error\\s+(\\d+)$/i.exec(responseBody.trim())?.[1] ?? '';\n }\n if (!code) return false;\n if (code !== '940' && !SEGMENT_ERROR_MESSAGE_KEYS[code]) return false;\n if (segmentErrorHandledRef.current) return true;\n segmentErrorHandledRef.current = true;\n\n if (code === '940') {\n // Playback token expired (e.g. paused for too long) — a reload re-issues it.\n console.warn('Segment response returned X-Error-Code 940 (token expired), reloading page...');\n window.location.reload();\n return true;\n }\n\n console.warn(`Segment response returned X-Error-Code ${code}, blocking playback`);\n try {\n videoElementRef.current?.pause();\n } catch {}\n onPlaybackBlocked?.(SEGMENT_ERROR_MESSAGE_KEYS[code]);\n return true;\n }, [onPlaybackBlocked]);\n\n // Checks a Shaka BAD_HTTP_STATUS (1001) error for a segment response\n // carrying a blocking X-Error-Code. Non-2xx segment responses never reach\n // response filters, so the code arrives via the error's headers (data[3])\n // or response body (data[2]). Reaches us through Shaka's 'error' event\n // mid-playback, or as a load() rejection when the init segment fails.\n const handleSegmentHttpError = useCallback((error: any): boolean => {\n if (error?.code !== 1001 || !Array.isArray(error?.data)) return false;\n if (error.data[4] !== (shaka as any).net.NetworkingEngine.RequestType.SEGMENT) return false;\n return handleSegmentErrorCode(error.data[3]?.['x-error-code'], error.data[2]);\n }, [handleSegmentErrorCode]);\n\n // Internal function to handle the actual player initialization logic\n const initializePlayerInternal = useCallback(async (video: HTMLVideoElement) => {\n try {\n // ============================================================\n // LIFECYCLE COORDINATION & PLAYER CREATION\n // ============================================================\n // Serialize with any pending destroy to avoid MediaSource races\n if (destroyInProgressRef.current) {\n try {\n await destroyInProgressRef.current;\n } catch {\n // Ignore destroy errors here; we'll proceed with a clean init\n }\n }\n\n const myInitId = ++initSequenceRef.current;\n activeInitIdRef.current = myInitId;\n\n // Store video element reference for retry functionality\n videoElementRef.current = video;\n // Install polyfills\n shaka.polyfill.installAll();\n\n // Check for browser support\n if (!shaka.Player.isBrowserSupported()) {\n throw new Error('Browser not supported by Shaka Player');\n }\n \n // If a destroy was requested meanwhile, abort early\n if (isDestroyingRef.current) {\n return;\n }\n\n // Create player instance first, then attach the video element.\n const player = new (shaka.Player as any)();\n playerRef.current = player;\n\n // Attach to the video element (recommended approach since Shaka v4).\n await (player as any).attach(video);\n\n // ============================================================\n // PLAYER CONFIGURATION (DVR, Streaming Settings)\n // ============================================================\n // Configure default settings for expanding DVR window in live streams\n // This ensures the DVR window grows from the origin rather than sliding forward\n const defaultConfig = {\n manifest: {\n // Override availability window to allow DVR window to grow from start\n // Set to a very large value (24 hours in seconds) to effectively allow unlimited growth\n availabilityWindowOverride: 86400, // 24 hours in seconds\n },\n streaming: {\n // Allow seeking to any point within the availability window\n safeSeekOffset: 5, // 5 seconds from live edge to prevent buffering\n // Increase tolerance for manifest timing inaccuracies in live streams\n inaccurateManifestTolerance: 2,\n }\n };\n\n // Apply default config first\n player.configure(defaultConfig);\n\n // Configure player with user-provided config (can override defaults)\n if (shakaConfig) {\n player.configure(shakaConfig);\n }\n \n const playlistSrc = normalizeSource(src);\n const manifestUrl = getManifestUrl();\n let playlistId = playlistSrc.id;\n const isDRM = Boolean(playlistSrc.drm?.widevine || playlistSrc.drm?.fairplay || playlistSrc.drm?.playready);\n\n // Reset per-load guards\n storedPersistentThisLoadRef.current = false;\n segmentErrorHandledRef.current = false;\n\n // If init was superseded, tear down the created player and exit\n if (activeInitIdRef.current !== myInitId || isDestroyingRef.current) {\n try { await player.destroy(); } catch {}\n if (playerRef.current === player) playerRef.current = null;\n return;\n }\n\n // ============================================================\n // DRM CONFIGURATION\n // ============================================================\n \n // Handle persistent licenses for DRM content\n let storedSessionsMetadata: PersistentSessionMetadata[] = [];\n if (isDRM && playlistId) {\n storedSessionsMetadata = retrievePersistentLicense(playlistId, playlistSrc.drm?.licenseCacheKey ?? \"\");\n }\n\n if (isDRM) {\n // --- DRM Server & Key System Configuration ---\n const drmConfig: any = {\n servers: {\n 'com.widevine.alpha': playlistSrc.drm?.widevine?.licenseUrl,\n 'com.microsoft.playready': playlistSrc.drm?.playready?.licenseUrl,\n 'com.apple.fps': playlistSrc.drm?.fairplay?.licenseUrl,\n },\n keySystemsMapping: {\n // Fall back or reroute to the platform's recommended PlayReady CDM if needed\n 'com.microsoft.playready': 'com.microsoft.playready.recommendation'\n },\n ...(playlistSrc.drm?.fairplay && {\n advanced: {\n 'com.apple.fps': {\n serverCertificateUri: playlistSrc.drm.fairplay.certificateUrl,\n }\n }\n })\n };\n\n drmConfig.advanced = {\n ...drmConfig.advanced,\n 'com.widevine.alpha': {\n ...drmConfig.advanced?.['com.widevine.alpha'],\n sessionType: supportsWidevinePersistentLicenses() ? 'persistent-license' : 'temporary'\n },\n 'com.microsoft.playready': {\n ...drmConfig.advanced?.['com.microsoft.playready'],\n sessionType: 'persistent-license' // PlayReady seems to always require persistent-license (temporary won't work, not compatible with license server response)\n },\n 'com.apple.fps': {\n ...drmConfig.advanced?.['com.apple.fps'],\n sessionType: 'temporary' // FairPlay always uses temporary sessions - Safari won't play with persistent-license\n }\n };\n\n // Configure persistent session settings if we have stored sessions\n if (storedSessionsMetadata.length > 0) {\n drmConfig.persistentSessionOnlinePlayback = true;\n drmConfig.persistentSessionsMetadata = storedSessionsMetadata.map(session => ({\n sessionId: session.sessionId,\n initData: new Uint8Array(session.initData),\n initDataType: session.initDataType\n }));\n }\n\n usingPersistentLicenseRef.current = storedSessionsMetadata.length > 0;\n\n player.configure({ drm: drmConfig });\n\n // --- Stale License Recovery: 'waitingforkey' Handler ---\n // Detect situations where the browser is waiting for a decryption key but none becomes usable\n // This can happen when a persisted license is invalid/stale. We listen for 'waitingforkey'\n // and, if playback does not resume shortly, clear cached licenses and reload.\n const clearWaitingForKeyTimer = () => {\n if (waitingForKeyTimerRef.current !== null) {\n window.clearTimeout(waitingForKeyTimerRef.current);\n waitingForKeyTimerRef.current = null;\n }\n };\n\n const onPlaybackResumed = () => {\n clearWaitingForKeyTimer();\n };\n\n const onWaitingForKey = () => {\n // Only attempt recovery when we intentionally tried to use a cached persistent license\n if (!usingPersistentLicenseRef.current) return;\n // Avoid stacking multiple timers or looping retries\n if (isRetrying) return;\n clearWaitingForKeyTimer();\n\n // Give the player a short window to provide/refresh keys normally\n waitingForKeyTimerRef.current = window.setTimeout(async () => {\n try {\n if (isRetrying) return;\n\n // If we are still stuck without keys, clear cached persistent licenses and retry\n console.warn('Stuck waiting for decryption key; attempting license cache reset and reload...');\n const clearedCount = clearAllPersistentLicenses();\n\n if (clearedCount > 0 && playerRef.current) {\n setIsRetrying(true);\n await playerRef.current.load(getManifestUrl());\n console.log('Reloaded manifest after clearing cached licenses');\n } else {\n console.warn('No cached licenses found to clear, skipping reload');\n }\n } catch (e) {\n console.error('Failed during recovery from waitingforkey state:', e);\n onError?.(e as Error);\n } finally {\n setIsRetrying(false);\n clearWaitingForKeyTimer();\n }\n }, 3000);\n };\n\n // Attach listeners to the video element\n try {\n (video as any).addEventListener('waitingforkey', onWaitingForKey as any);\n video.addEventListener('playing', onPlaybackResumed);\n video.addEventListener('ended', onPlaybackResumed);\n video.addEventListener('pause', onPlaybackResumed);\n waitingForKeyHandlerRef.current = onWaitingForKey;\n playbackResumedHandlerRef.current = onPlaybackResumed;\n } catch (e) {\n console.warn('Failed to attach waitingforkey/playback listeners:', e);\n }\n\n // --- Network Engine Filters: Auth Headers & FairPlay Decoding ---\n const netEngine = player.getNetworkingEngine();\n if (netEngine) {\n netEngine.registerRequestFilter((type: any, request: any) => {\n switch (type) {\n case (shaka as any).net.NetworkingEngine.RequestType.LICENSE:\n if (publicKey) {\n request.headers[\"authorization\"] = `Bearer ${publicKey}`;\n }\n if (mottoToken) {\n