UNPKG

stormcloud-video-player

Version:

Ad-first HLS video player with SCTE-35 support and Google IMA integration for precise ad break alignment

1 lines 180 kB
{"version":3,"sources":["../../src/ui/StormcloudVideoPlayer.tsx","../../src/player/StormcloudVideoPlayer.ts","../../src/sdk/ima.ts","../../src/utils/tracking.ts"],"sourcesContent":["import React, { useEffect, useRef, useMemo } from \"react\";\nimport { StormcloudVideoPlayer } from \"../player/StormcloudVideoPlayer\";\nimport type { StormcloudVideoPlayerConfig } from \"../types\";\nimport {\n FaPlay,\n FaPause,\n FaVolumeUp,\n FaVolumeMute,\n FaVolumeDown,\n FaExpand,\n FaCompress,\n FaSpinner,\n} from \"react-icons/fa\";\n\nexport type StormcloudVideoPlayerProps = Omit<\n StormcloudVideoPlayerConfig,\n \"videoElement\"\n> &\n React.VideoHTMLAttributes<HTMLVideoElement> & {\n onReady?: (player: StormcloudVideoPlayer) => void;\n wrapperClassName?: string;\n wrapperStyle?: React.CSSProperties;\n licenseKey?: string;\n };\n\nconst CRITICAL_PROPS = [\n \"src\",\n \"allowNativeHls\",\n \"licenseKey\",\n \"lowLatencyMode\",\n \"driftToleranceMs\",\n] as const;\n\nexport const StormcloudVideoPlayerComponent: React.FC<StormcloudVideoPlayerProps> =\n React.memo(\n (props) => {\n const {\n src,\n autoplay,\n muted,\n lowLatencyMode,\n allowNativeHls,\n driftToleranceMs,\n immediateManifestAds,\n debugAdTiming,\n showCustomControls,\n onVolumeToggle,\n onFullscreenToggle,\n onControlClick,\n onReady,\n wrapperClassName,\n wrapperStyle,\n className,\n style,\n controls,\n playsInline,\n preload,\n poster,\n children,\n licenseKey,\n ...restVideoAttrs\n } = props;\n\n const videoRef = useRef<HTMLVideoElement | null>(null);\n const playerRef = useRef<StormcloudVideoPlayer | null>(null);\n const [adStatus, setAdStatus] = React.useState<{\n showAds: boolean;\n currentIndex: number;\n totalAds: number;\n }>({ showAds: false, currentIndex: 0, totalAds: 0 });\n\n const [shouldShowNativeControls, setShouldShowNativeControls] =\n React.useState(true);\n\n const [isMuted, setIsMuted] = React.useState(false);\n const [isFullscreen, setIsFullscreen] = React.useState(false);\n const [isPlaying, setIsPlaying] = React.useState(false);\n const [currentTime, setCurrentTime] = React.useState(0);\n const [duration, setDuration] = React.useState(0);\n const [volume, setVolume] = React.useState(1);\n const [playbackRate, setPlaybackRate] = React.useState(1);\n const [showVolumeSlider, setShowVolumeSlider] = React.useState(false);\n const [showSpeedMenu, setShowSpeedMenu] = React.useState(false);\n const [isLoading, setIsLoading] = React.useState(true);\n const [isBuffering, setIsBuffering] = React.useState(false);\n const [showCenterPlay, setShowCenterPlay] = React.useState(false);\n\n const formatTime = (seconds: number): string => {\n if (!isFinite(seconds)) return \"0:00:00\";\n const hours = Math.floor(seconds / 3600);\n const minutes = Math.floor((seconds % 3600) / 60);\n const remainingSeconds = Math.floor(seconds % 60);\n return `${hours}:${minutes\n .toString()\n .padStart(2, \"0\")}:${remainingSeconds.toString().padStart(2, \"0\")}`;\n };\n\n const handlePlayPause = () => {\n if (videoRef.current) {\n if (videoRef.current.paused) {\n videoRef.current.play();\n setShowCenterPlay(false);\n } else {\n videoRef.current.pause();\n setShowCenterPlay(true);\n }\n }\n };\n\n const handleCenterPlayClick = () => {\n if (videoRef.current && videoRef.current.paused) {\n videoRef.current.play();\n setShowCenterPlay(false);\n }\n };\n\n const handleTimelineSeek = (e: React.MouseEvent<HTMLDivElement>) => {\n if (videoRef.current && duration > 0 && isFinite(duration)) {\n const rect = e.currentTarget.getBoundingClientRect();\n const clickX = e.clientX - rect.left;\n const progress = Math.max(0, Math.min(1, clickX / rect.width));\n const newTime = progress * duration;\n\n if (isFinite(newTime) && newTime >= 0 && newTime <= duration) {\n videoRef.current.currentTime = newTime;\n }\n }\n };\n\n const handleVolumeChange = (newVolume: number) => {\n if (videoRef.current && isFinite(newVolume)) {\n const clampedVolume = Math.max(0, Math.min(1, newVolume));\n videoRef.current.volume = clampedVolume;\n videoRef.current.muted = clampedVolume === 0;\n }\n };\n\n const handlePlaybackRateChange = (rate: number) => {\n if (videoRef.current && isFinite(rate) && rate > 0) {\n videoRef.current.playbackRate = rate;\n }\n setShowSpeedMenu(false);\n };\n\n const isHlsStream =\n src?.toLowerCase().includes(\".m3u8\") ||\n src?.toLowerCase().includes(\"/hls/\");\n const shouldShowEnhancedControls =\n showCustomControls && (isHlsStream ? allowNativeHls : true);\n\n const criticalPropsKey = useMemo(() => {\n return CRITICAL_PROPS.map((prop) => `${prop}:${props[prop]}`).join(\"|\");\n }, [src, allowNativeHls, licenseKey, lowLatencyMode, driftToleranceMs]);\n\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const el = videoRef.current;\n if (!el || !src) return;\n\n if (playerRef.current) {\n try {\n playerRef.current.destroy();\n } catch {}\n playerRef.current = null;\n }\n\n const cfg: StormcloudVideoPlayerConfig = {\n src,\n videoElement: el,\n } as StormcloudVideoPlayerConfig;\n if (autoplay !== undefined) cfg.autoplay = autoplay;\n if (muted !== undefined) cfg.muted = muted;\n if (lowLatencyMode !== undefined) cfg.lowLatencyMode = lowLatencyMode;\n if (allowNativeHls !== undefined) cfg.allowNativeHls = allowNativeHls;\n if (driftToleranceMs !== undefined)\n cfg.driftToleranceMs = driftToleranceMs;\n if (immediateManifestAds !== undefined)\n cfg.immediateManifestAds = immediateManifestAds;\n if (debugAdTiming !== undefined) cfg.debugAdTiming = debugAdTiming;\n if (showCustomControls !== undefined)\n cfg.showCustomControls = showCustomControls;\n if (onVolumeToggle !== undefined) cfg.onVolumeToggle = onVolumeToggle;\n if (onFullscreenToggle !== undefined)\n cfg.onFullscreenToggle = onFullscreenToggle;\n if (onControlClick !== undefined) cfg.onControlClick = onControlClick;\n if (licenseKey !== undefined) cfg.licenseKey = licenseKey;\n\n const player = new StormcloudVideoPlayer(cfg);\n playerRef.current = player;\n player\n .load()\n .then(() => {\n const showNative = player.shouldShowNativeControls();\n setShouldShowNativeControls(showNative);\n onReady?.(player);\n })\n .catch((error) => {\n console.error(\n \"StormcloudVideoPlayer: Failed to load player:\",\n error\n );\n onReady?.(player);\n });\n\n return () => {\n try {\n player.destroy();\n } catch {}\n playerRef.current = null;\n };\n }, [criticalPropsKey]);\n\n useEffect(() => {\n if (!playerRef.current) return;\n\n try {\n if (autoplay !== undefined && playerRef.current.videoElement) {\n playerRef.current.videoElement.autoplay = autoplay;\n }\n if (muted !== undefined && playerRef.current.videoElement) {\n playerRef.current.videoElement.muted = muted;\n }\n } catch (error) {\n console.warn(\"Failed to update player properties:\", error);\n }\n }, [autoplay, muted]);\n\n useEffect(() => {\n if (!playerRef.current) return;\n\n const checkAdStatus = () => {\n if (playerRef.current) {\n const showAds = playerRef.current.isShowingAds();\n const currentIndex = playerRef.current.getCurrentAdIndex();\n const totalAds = playerRef.current.getTotalAdsInBreak();\n\n setAdStatus((prev) => {\n if (\n prev.showAds !== showAds ||\n prev.currentIndex !== currentIndex ||\n prev.totalAds !== totalAds\n ) {\n return { showAds, currentIndex, totalAds };\n }\n return prev;\n });\n }\n };\n\n const interval = setInterval(checkAdStatus, 100);\n return () => clearInterval(interval);\n }, []);\n\n useEffect(() => {\n if (typeof window === \"undefined\" || !playerRef.current) return;\n\n const handleResize = () => {\n if (playerRef.current && videoRef.current) {\n if (typeof playerRef.current.resize === \"function\") {\n playerRef.current.resize();\n }\n }\n };\n\n window.addEventListener(\"resize\", handleResize);\n return () => window.removeEventListener(\"resize\", handleResize);\n }, []);\n\n useEffect(() => {\n if (!playerRef.current || !videoRef.current) return;\n\n const updateStates = () => {\n if (playerRef.current && videoRef.current) {\n setIsMuted(playerRef.current.isMuted());\n setIsPlaying(!videoRef.current.paused);\n\n const currentTimeValue = videoRef.current.currentTime;\n setCurrentTime(isFinite(currentTimeValue) ? currentTimeValue : 0);\n\n const durationValue = videoRef.current.duration;\n setDuration(isFinite(durationValue) ? durationValue : 0);\n\n const volumeValue = videoRef.current.volume;\n setVolume(\n isFinite(volumeValue) ? Math.max(0, Math.min(1, volumeValue)) : 1\n );\n\n const rateValue = videoRef.current.playbackRate;\n setPlaybackRate(\n isFinite(rateValue) && rateValue > 0 ? rateValue : 1\n );\n }\n setIsFullscreen(\n document.fullscreenElement === videoRef.current?.parentElement\n );\n };\n\n const interval = setInterval(updateStates, 200);\n\n const handleFullscreenChange = () => {\n setIsFullscreen(\n document.fullscreenElement === videoRef.current?.parentElement\n );\n };\n\n document.addEventListener(\"fullscreenchange\", handleFullscreenChange);\n\n return () => {\n clearInterval(interval);\n document.removeEventListener(\n \"fullscreenchange\",\n handleFullscreenChange\n );\n };\n }, []);\n\n useEffect(() => {\n if (!videoRef.current) return;\n\n const handleLoadedMetadata = () => {\n if (videoRef.current) {\n const video = videoRef.current;\n void video.offsetHeight;\n }\n };\n\n const handleLoadStart = () => {\n setIsLoading(true);\n setIsBuffering(false);\n };\n\n const handleCanPlay = () => {\n setIsLoading(false);\n setIsBuffering(false);\n };\n\n const handleWaiting = () => {\n setIsBuffering(true);\n };\n\n const handlePlaying = () => {\n setIsLoading(false);\n setIsBuffering(false);\n setShowCenterPlay(false);\n };\n\n const handlePause = () => {\n if (playerRef.current && !playerRef.current.isShowingAds()) {\n setShowCenterPlay(true);\n } else {\n setShowCenterPlay(false);\n }\n };\n\n const handleEnded = () => {\n setShowCenterPlay(true);\n };\n\n const video = videoRef.current;\n video.addEventListener(\"loadstart\", handleLoadStart);\n video.addEventListener(\"loadedmetadata\", handleLoadedMetadata);\n video.addEventListener(\"loadeddata\", handleLoadedMetadata);\n video.addEventListener(\"canplay\", handleCanPlay);\n video.addEventListener(\"waiting\", handleWaiting);\n video.addEventListener(\"playing\", handlePlaying);\n video.addEventListener(\"pause\", handlePause);\n video.addEventListener(\"ended\", handleEnded);\n\n if (video.paused) {\n setShowCenterPlay(true);\n }\n\n return () => {\n video.removeEventListener(\"loadstart\", handleLoadStart);\n video.removeEventListener(\"loadedmetadata\", handleLoadedMetadata);\n video.removeEventListener(\"loadeddata\", handleLoadedMetadata);\n video.removeEventListener(\"canplay\", handleCanPlay);\n video.removeEventListener(\"waiting\", handleWaiting);\n video.removeEventListener(\"playing\", handlePlaying);\n video.removeEventListener(\"pause\", handlePause);\n video.removeEventListener(\"ended\", handleEnded);\n };\n }, []);\n\n return (\n <>\n <style>\n {`\n @keyframes spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n }\n \n .stormcloud-video-wrapper:fullscreen {\n border-radius: 0 !important;\n box-shadow: none !important;\n width: 100vw !important;\n height: 100vh !important;\n max-width: 100vw !important;\n max-height: 100vh !important;\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n z-index: 999999 !important;\n background: #000 !important;\n display: flex !important;\n align-items: center !important;\n justify-content: center !important;\n }\n \n .stormcloud-video-wrapper:has(*:fullscreen) {\n border-radius: 0 !important;\n box-shadow: none !important;\n width: 100vw !important;\n height: 100vh !important;\n max-width: 100vw !important;\n max-height: 100vh !important;\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n z-index: 999999 !important;\n background: #000 !important;\n display: flex !important;\n align-items: center !important;\n justify-content: center !important;\n }\n \n *:fullscreen {\n width: 100vw !important;\n height: 100vh !important;\n max-width: 100vw !important;\n max-height: 100vh !important;\n position: fixed !important;\n top: 0 !important;\n left: 0 !important;\n z-index: 999999 !important;\n background: #000 !important;\n }\n `}\n </style>\n <div\n className={`stormcloud-video-wrapper ${wrapperClassName || \"\"}`}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n position: isFullscreen ? \"fixed\" : \"relative\",\n top: isFullscreen ? 0 : undefined,\n left: isFullscreen ? 0 : undefined,\n overflow: \"hidden\",\n width: isFullscreen ? \"100vw\" : \"100%\",\n height: isFullscreen ? \"100vh\" : \"auto\",\n minHeight: isFullscreen ? \"100vh\" : \"auto\",\n maxWidth: isFullscreen ? \"100vw\" : \"100%\",\n maxHeight: isFullscreen ? \"100vh\" : \"none\",\n zIndex: isFullscreen ? 999999 : undefined,\n backgroundColor: isFullscreen ? \"#000\" : undefined,\n borderRadius: isFullscreen ? 0 : undefined,\n boxShadow: isFullscreen ? \"none\" : undefined,\n ...wrapperStyle,\n }}\n >\n <video\n ref={videoRef}\n className={className}\n style={{\n display: \"block\",\n width: \"100%\",\n height: isFullscreen ? \"100%\" : \"auto\",\n maxWidth: \"100%\",\n maxHeight: isFullscreen ? \"100%\" : \"none\",\n objectFit: isFullscreen ? \"cover\" : \"contain\",\n backgroundColor: \"#000\",\n aspectRatio: isFullscreen ? \"unset\" : undefined,\n ...style,\n }}\n controls={\n shouldShowNativeControls && controls && !showCustomControls\n }\n playsInline={playsInline}\n preload={preload}\n poster={poster}\n {...restVideoAttrs}\n >\n {children}\n </video>\n\n {(isLoading || isBuffering) && (\n <div\n style={{\n position: \"absolute\",\n top: \"50%\",\n left: \"50%\",\n transform: \"translate(-50%, -50%)\",\n zIndex: 20,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n background:\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.8) 0%, rgba(20, 20, 20, 0.6) 100%)\",\n width: \"80px\",\n height: \"80px\",\n borderRadius: \"50%\",\n backdropFilter: \"blur(20px)\",\n boxShadow:\n \"0 12px 40px rgba(0, 0, 0, 0.6), inset 0 2px 0 rgba(255, 255, 255, 0.1)\",\n }}\n >\n <FaSpinner\n size={28}\n color=\"white\"\n style={{\n animation: \"spin 1s linear infinite\",\n filter: \"drop-shadow(0 3px 6px rgba(0, 0, 0, 0.8))\",\n }}\n />\n </div>\n )}\n\n {showCenterPlay &&\n !isLoading &&\n !isBuffering &&\n !adStatus.showAds && (\n <div\n onClick={handleCenterPlayClick}\n style={{\n position: \"absolute\",\n top: \"50%\",\n left: \"50%\",\n transform: \"translate(-50%, -50%)\",\n zIndex: 15,\n cursor: \"pointer\",\n background:\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(20, 20, 20, 0.8) 100%)\",\n borderRadius: \"50%\",\n width: \"100px\",\n height: \"100px\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n backdropFilter: \"blur(20px)\",\n border: \"3px solid rgba(255, 255, 255, 0.8)\",\n boxShadow:\n \"0 12px 40px rgba(0, 0, 0, 0.8), inset 0 2px 0 rgba(255, 255, 255, 0.3)\",\n transition: \"all 0.3s cubic-bezier(0.4, 0, 0.2, 1)\",\n }}\n onMouseEnter={(e) => {\n const target = e.currentTarget as HTMLElement;\n target.style.transform = \"translate(-50%, -50%) scale(1.1)\";\n target.style.background =\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.95) 0%, rgba(40, 40, 40, 0.9) 100%)\";\n target.style.boxShadow =\n \"0 16px 48px rgba(0, 0, 0, 0.9), inset 0 2px 0 rgba(255, 255, 255, 0.4)\";\n target.style.borderColor = \"rgba(255, 255, 255, 0.9)\";\n }}\n onMouseLeave={(e) => {\n const target = e.currentTarget as HTMLElement;\n target.style.transform = \"translate(-50%, -50%) scale(1)\";\n target.style.background =\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(20, 20, 20, 0.8) 100%)\";\n target.style.boxShadow =\n \"0 12px 40px rgba(0, 0, 0, 0.8), inset 0 2px 0 rgba(255, 255, 255, 0.3)\";\n target.style.borderColor = \"rgba(255, 255, 255, 0.8)\";\n }}\n title=\"Play\"\n >\n <FaPlay\n size={36}\n color=\"white\"\n style={{\n marginLeft: \"6px\",\n filter: \"drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))\",\n }}\n />\n </div>\n )}\n\n {shouldShowEnhancedControls ? (\n <>\n <div\n style={{\n position: \"absolute\",\n bottom: 0,\n left: 0,\n right: 0,\n background:\n \"linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.8) 100%)\",\n padding: \"20px 16px 16px\",\n zIndex: 10,\n }}\n >\n <div\n style={{\n width: \"100%\",\n height: \"8px\",\n background:\n \"linear-gradient(90deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.1) 100%)\",\n borderRadius: \"8px\",\n marginBottom: \"16px\",\n cursor: \"pointer\",\n position: \"relative\",\n backdropFilter: \"blur(5px)\",\n border: \"1px solid rgba(255, 255, 255, 0.1)\",\n boxShadow: \"inset 0 2px 4px rgba(0, 0, 0, 0.2)\",\n }}\n onClick={handleTimelineSeek}\n >\n <div\n style={{\n height: \"100%\",\n background:\n \"linear-gradient(90deg, rgba(139, 92, 246, 0.9) 0%, rgba(59, 130, 246, 0.8) 50%, rgba(34, 197, 94, 0.9) 100%)\",\n borderRadius: \"8px\",\n width: `${\n duration > 0 ? (currentTime / duration) * 100 : 0\n }%`,\n transition: \"width 0.2s cubic-bezier(0.4, 0, 0.2, 1)\",\n boxShadow: \"0 2px 8px rgba(139, 92, 246, 0.4)\",\n }}\n />\n <div\n style={{\n position: \"absolute\",\n top: \"-6px\",\n right: `${\n duration > 0\n ? 100 - (currentTime / duration) * 100\n : 100\n }%`,\n width: \"20px\",\n height: \"20px\",\n background:\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(240, 240, 240, 0.9) 100%)\",\n borderRadius: \"50%\",\n border: \"3px solid rgba(139, 92, 246, 0.8)\",\n boxShadow:\n \"0 4px 16px rgba(139, 92, 246, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.8)\",\n transform: \"translateX(50%)\",\n transition: \"all 0.2s cubic-bezier(0.4, 0, 0.2, 1)\",\n }}\n />\n </div>\n\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"space-between\",\n color: \"white\",\n }}\n >\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: \"12px\",\n }}\n >\n <button\n onClick={handlePlayPause}\n style={{\n background:\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%)\",\n backdropFilter: \"blur(10px)\",\n border: \"1px solid rgba(255, 255, 255, 0.2)\",\n color: \"#ffffff\",\n cursor: \"pointer\",\n padding: \"12px\",\n borderRadius: \"12px\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n transition: \"all 0.3s cubic-bezier(0.4, 0, 0.2, 1)\",\n boxShadow:\n \"0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)\",\n minWidth: \"48px\",\n minHeight: \"48px\",\n }}\n onMouseEnter={(e) => {\n const target = e.target as HTMLElement;\n target.style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.25) 0%, rgba(255, 255, 255, 0.1) 100%)\";\n target.style.transform =\n \"translateY(-2px) scale(1.05)\";\n target.style.boxShadow =\n \"0 12px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.3)\";\n }}\n onMouseLeave={(e) => {\n const target = e.target as HTMLElement;\n target.style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%)\";\n target.style.transform = \"translateY(0) scale(1)\";\n target.style.boxShadow =\n \"0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.2)\";\n }}\n title={isPlaying ? \"Pause\" : \"Play\"}\n >\n {isPlaying ? (\n <FaPause\n size={20}\n style={{ filter: \"drop-shadow(0 0 0 transparent)\" }}\n />\n ) : (\n <FaPlay\n size={20}\n style={{ filter: \"drop-shadow(0 0 0 transparent)\" }}\n />\n )}\n </button>\n\n <div\n style={{\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n padding: \"8px\",\n margin: \"-8px\",\n }}\n onMouseEnter={() => setShowVolumeSlider(true)}\n onMouseLeave={() => setShowVolumeSlider(false)}\n >\n <button\n onClick={() => {\n if (onVolumeToggle) {\n onVolumeToggle();\n } else if (playerRef.current) {\n playerRef.current.toggleMute();\n }\n }}\n style={{\n background:\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 100%)\",\n backdropFilter: \"blur(8px)\",\n border: \"1px solid rgba(255, 255, 255, 0.15)\",\n color: isMuted ? \"#ff6b6b\" : \"#ffffff\",\n cursor: \"pointer\",\n padding: \"10px\",\n borderRadius: \"10px\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n transition: \"all 0.3s cubic-bezier(0.4, 0, 0.2, 1)\",\n boxShadow:\n \"0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.1)\",\n minWidth: \"40px\",\n minHeight: \"40px\",\n }}\n onMouseEnter={(e) => {\n const target = e.target as HTMLElement;\n target.style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.08) 100%)\";\n target.style.transform =\n \"translateY(-1px) scale(1.03)\";\n target.style.boxShadow =\n \"0 8px 28px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.2)\";\n }}\n onMouseLeave={(e) => {\n const target = e.target as HTMLElement;\n target.style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 100%)\";\n target.style.transform = \"translateY(0) scale(1)\";\n target.style.boxShadow =\n \"0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.1)\";\n }}\n title={isMuted ? \"Unmute\" : \"Mute\"}\n >\n {isMuted || volume === 0 ? (\n <FaVolumeMute\n size={16}\n style={{\n filter: \"drop-shadow(0 0 0 transparent)\",\n }}\n />\n ) : volume < 0.5 ? (\n <FaVolumeDown\n size={16}\n style={{\n filter: \"drop-shadow(0 0 0 transparent)\",\n }}\n />\n ) : (\n <FaVolumeUp\n size={16}\n style={{\n filter: \"drop-shadow(0 0 0 transparent)\",\n }}\n />\n )}\n </button>\n\n {showVolumeSlider && (\n <>\n <div\n style={{\n position: \"absolute\",\n bottom: \"100%\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n width: \"60px\",\n height: \"20px\",\n marginBottom: \"-16px\",\n zIndex: 9,\n }}\n onMouseEnter={() => setShowVolumeSlider(true)}\n onMouseLeave={() => setShowVolumeSlider(false)}\n />\n <div\n style={{\n position: \"absolute\",\n bottom: \"100%\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n marginBottom: \"4px\",\n background:\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.85) 0%, rgba(20, 20, 20, 0.9) 100%)\",\n backdropFilter: \"blur(15px)\",\n padding: \"16px 12px\",\n borderRadius: \"12px\",\n border: \"1px solid rgba(255, 255, 255, 0.1)\",\n display: \"flex\",\n flexDirection: \"column\",\n alignItems: \"center\",\n height: \"130px\",\n boxShadow:\n \"0 12px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1)\",\n zIndex: 10,\n }}\n onMouseEnter={() => setShowVolumeSlider(true)}\n onMouseLeave={() => setShowVolumeSlider(false)}\n >\n <input\n type=\"range\"\n min=\"0\"\n max=\"1\"\n step=\"0.01\"\n value={isMuted ? 0 : volume}\n onChange={(e) =>\n handleVolumeChange(parseFloat(e.target.value))\n }\n style={{\n writingMode: \"bt-lr\" as any,\n WebkitAppearance: \"slider-vertical\" as any,\n width: \"6px\",\n height: \"90px\",\n background:\n \"linear-gradient(180deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.1) 100%)\",\n borderRadius: \"3px\",\n outline: \"none\",\n cursor: \"pointer\",\n }}\n />\n </div>\n </>\n )}\n </div>\n\n <div\n style={{\n fontSize: \"14px\",\n fontFamily: \"monospace\",\n color: \"rgba(255, 255, 255, 0.9)\",\n }}\n >\n {formatTime(currentTime)} / {formatTime(duration)}\n </div>\n </div>\n\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: \"12px\",\n }}\n >\n <div style={{ position: \"relative\" }}>\n <button\n onClick={() => setShowSpeedMenu(!showSpeedMenu)}\n style={{\n background:\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 100%)\",\n backdropFilter: \"blur(8px)\",\n border: \"1px solid rgba(255, 255, 255, 0.12)\",\n color: \"#ffffff\",\n cursor: \"pointer\",\n padding: \"8px 14px\",\n borderRadius: \"8px\",\n fontSize: \"13px\",\n fontFamily: \"monospace\",\n fontWeight: \"600\",\n transition: \"all 0.3s cubic-bezier(0.4, 0, 0.2, 1)\",\n boxShadow:\n \"0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08)\",\n minWidth: \"50px\",\n }}\n onMouseEnter={(e) => {\n const target = e.target as HTMLElement;\n target.style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0.06) 100%)\";\n target.style.transform =\n \"translateY(-1px) scale(1.02)\";\n target.style.boxShadow =\n \"0 6px 20px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.15)\";\n }}\n onMouseLeave={(e) => {\n const target = e.target as HTMLElement;\n target.style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.03) 100%)\";\n target.style.transform = \"translateY(0) scale(1)\";\n target.style.boxShadow =\n \"0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08)\";\n }}\n title=\"Playback Speed\"\n >\n {playbackRate}x\n </button>\n\n {showSpeedMenu && (\n <div\n style={{\n position: \"absolute\",\n bottom: \"100%\",\n right: 0,\n marginBottom: \"12px\",\n background:\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(20, 20, 20, 0.95) 100%)\",\n backdropFilter: \"blur(20px)\",\n borderRadius: \"12px\",\n border: \"1px solid rgba(255, 255, 255, 0.1)\",\n overflow: \"hidden\",\n minWidth: \"90px\",\n boxShadow:\n \"0 16px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1)\",\n }}\n >\n {[0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2].map(\n (speed) => (\n <button\n key={speed}\n onClick={() =>\n handlePlaybackRateChange(speed)\n }\n style={{\n display: \"block\",\n width: \"100%\",\n padding: \"10px 16px\",\n background:\n playbackRate === speed\n ? \"linear-gradient(135deg, rgba(99, 102, 241, 0.8) 0%, rgba(139, 92, 246, 0.6) 100%)\"\n : \"transparent\",\n border: \"none\",\n color: \"white\",\n cursor: \"pointer\",\n fontSize: \"13px\",\n fontFamily: \"monospace\",\n fontWeight: \"600\",\n textAlign: \"center\",\n transition:\n \"all 0.2s cubic-bezier(0.4, 0, 0.2, 1)\",\n borderBottom:\n speed !== 2\n ? \"1px solid rgba(255, 255, 255, 0.05)\"\n : \"none\",\n }}\n onMouseEnter={(e) => {\n if (playbackRate !== speed) {\n (\n e.target as HTMLElement\n ).style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.05) 100%)\";\n }\n }}\n onMouseLeave={(e) => {\n if (playbackRate !== speed) {\n (\n e.target as HTMLElement\n ).style.background = \"transparent\";\n }\n }}\n >\n {speed}x\n </button>\n )\n )}\n </div>\n )}\n </div>\n\n <button\n onClick={() => {\n if (onFullscreenToggle) {\n onFullscreenToggle();\n } else if (playerRef.current) {\n playerRef.current\n .toggleFullscreen()\n .catch((err) => {\n console.error(\"Fullscreen error:\", err);\n });\n }\n }}\n style={{\n background:\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 100%)\",\n backdropFilter: \"blur(8px)\",\n border: \"1px solid rgba(255, 255, 255, 0.15)\",\n color: \"#ffffff\",\n cursor: \"pointer\",\n padding: \"10px\",\n borderRadius: \"10px\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n transition: \"all 0.3s cubic-bezier(0.4, 0, 0.2, 1)\",\n boxShadow:\n \"0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.1)\",\n minWidth: \"40px\",\n minHeight: \"40px\",\n }}\n onMouseEnter={(e) => {\n const target = e.target as HTMLElement;\n target.style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.08) 100%)\";\n target.style.transform =\n \"translateY(-1px) scale(1.03)\";\n target.style.boxShadow =\n \"0 8px 28px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.2)\";\n }}\n onMouseLeave={(e) => {\n const target = e.target as HTMLElement;\n target.style.background =\n \"linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.04) 100%)\";\n target.style.transform = \"translateY(0) scale(1)\";\n target.style.boxShadow =\n \"0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.1)\";\n }}\n title={\n isFullscreen ? \"Exit Fullscreen\" : \"Enter Fullscreen\"\n }\n >\n {isFullscreen ? (\n <FaCompress\n size={16}\n style={{ filter: \"drop-shadow(0 0 0 transparent)\" }}\n />\n ) : (\n <FaExpand\n size={16}\n style={{ filter: \"drop-shadow(0 0 0 transparent)\" }}\n />\n )}\n </button>\n </div>\n </div>\n </div>\n </>\n ) : (\n showCustomControls && (\n <div\n style={{\n position: \"absolute\",\n bottom: \"10px\",\n right: \"10px\",\n display: \"flex\",\n gap: \"8px\",\n zIndex: 10,\n }}\n >\n <div\n style={{\n position: \"relative\",\n display: \"flex\",\n alignItems: \"center\",\n padding: \"8px\",\n margin: \"-8px\",\n }}\n onMouseEnter={() => setShowVolumeSlider(true)}\n onMouseLeave={() => setShowVolumeSlider(false)}\n >\n <button\n onClick={() => {\n if (onVolumeToggle) {\n onVolumeToggle();\n } else if (playerRef.current) {\n playerRef.current.toggleMute();\n }\n }}\n onMouseEnter={(e) => {\n const target = e.currentTarget as HTMLButtonElement;\n target.style.transform = \"translateY(-3px) scale(1.08)\";\n target.style.boxShadow =\n \"0 12px 40px rgba(0, 0, 0, 0.8), 0 0 0 2px rgba(255, 255, 255, 0.8), inset 0 2px 0 rgba(255, 255, 255, 0.3)\";\n target.style.background =\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(40, 40, 40, 0.85) 100%)\";\n }}\n onMouseLeave={(e) => {\n const target = e.currentTarget as HTMLButtonElement;\n target.style.transform = \"translateY(0) scale(1)\";\n target.style.boxShadow =\n \"0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 2px rgba(255, 255, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2)\";\n target.style.background =\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.85) 0%, rgba(30, 30, 30, 0.8) 100%)\";\n }}\n style={{\n background:\n \"linear-gradient(135deg, rgba(0, 0, 0, 0.85) 0%, rgba(30, 30, 30, 0.8) 100%)\",\n color: isMuted ? \"#ff6b6b\" : \"#ffffff\",\n border: \"none\",\n borderRadius: \"16px\",\n padding: \"10px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n backdropFilter: \"blur(20px)\",\n boxShadow:\n \"0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 2px rgba(255, 255, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2)\",\n transition: \"all 0.4s cubic-bezier(0.4, 0, 0.2, 1)\",\n minWidth: \"44px\",\n minHeight: \"44px\",\n }}\n title={isMuted ? \"Unmute\" : \"Mute\"}\n >\n {isMuted || volume === 0 ? (\n <FaVolumeMute\n size={16}\n style={{\n filter: \"drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8))\",\n color: \"#ffffff\",\n }}\n />\n ) : volume < 0.5 ? (\n <FaVolumeDown\n size