import React from "react"; import { formatSeconds } from "../format"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { IconCamera, IconClosedCaptioningRegular, IconClosedCaptioningSolid, IconCompress, IconExpand, IconPause, IconPlay, IconVolume, IconVolumeMute, } from "../icons"; import { checkAutoplay } from "../util"; import LoaderRing from "./components/LoaderRing"; import SeekBar from "./SeekBar"; import { CaptionsRenderer } from "react-srv3"; import MediaSync, { MediaSyncRef, MediaSyncState } from "./MediaSync"; import { NextImage } from "../NextImage"; type CaptionFormat = "srv3" | "srt"; type CaptionsTrack = { lang: string; src: string; format?: CaptionFormat; }; const detectFormat = (src: string): CaptionFormat => { const clean = src.split("?")[0].split("#")[0].toLowerCase(); if (clean.endsWith(".srt")) return "srt"; if (clean.endsWith(".srv3") || clean.endsWith(".xml")) return "srv3"; return "srv3"; }; const srtToSrv3 = (srt: string): string => { const timestampToMs = (ts: string): number => { // HH:MM:SS,mmm or HH:MM:SS.mmm const m = ts .replace(",", ".") .split(":") .map(Number); let h = 0; let mm = 0; let s = 0; if (m.length === 3) [h, mm, s] = m; else if (m.length === 2) [mm, s] = m; else s = m[0] || 0; return Math.round((h * 3600 + mm * 60 + s) * 1000); }; const escapeXml = (s: string) => s .replace(/&/g, "&") .replace(//g, ">"); const blocks = srt.replace(/\r\n/g, "\n").replace(/^\uFEFF/, "").trim().split(/\n\s*\n/); const entries = blocks .map((block) => { const lines = block.split("\n").filter((l) => l.trim() !== ""); if (lines.length < 2) return null; // Drop the numeric index line if present const timingLine = /^\d+$/.test(lines[0].trim()) ? lines[1] : lines[0]; const textStart = /^\d+$/.test(lines[0].trim()) ? 2 : 1; const text = lines.slice(textStart).join("\n"); const match = timingLine.match( /(\d{1,2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}[.,]\d{3})/ ); if (!match) return null; const start = timestampToMs(match[1]); const end = timestampToMs(match[2]); const dur = Math.max(0, end - start); const body = escapeXml(text); return `

${body}

`; }) .filter(Boolean); return ( `\n` + `\n` + `\n` + `\n` + `\n` + `\n` + `\n` + entries.join("\n") + `\n\n` + `` ); }; export type VideoPlayerProps = { srcVideo: string; srcAudio: string; srcPoster?: string; captions?: CaptionsTrack[]; onPlaybackProgress?: (progress: number) => any; autoplay?: boolean; showWatermark?: boolean; videoId?: string; }; const VideoPlayer2 = (props: VideoPlayerProps) => { const { srcVideo, srcAudio, srcPoster, videoId, showWatermark } = props; const captions = props.captions || []; const hasCaptions = captions.length > 0; const enCaptionIndex = captions.findIndex( (cap) => cap.lang === "en" || cap.lang.startsWith("en-") ); const refSelf = React.useRef(null); const refAudio = React.useRef(null); const refVideo = React.useRef(null); const refMedia = React.useRef(null); const lastMediaState = React.useRef(null); const [bufferProgress, setBufferProgress] = React.useState(0); const [lastActive, setLastActive] = React.useState(Date.now()); const [isVideoErrored, setIsVideoErrored] = React.useState(false); const [videoErrorMessage, setVideoErrorMessage] = React.useState(""); const [srv3CaptionXMLs, setSrv3CaptionXMLs] = React.useState([]); const [audioVolume, setAudioVolume] = useLocalStorage("player:volume", 1); const [isFullscreen, setIsFullscreen] = React.useState(false); const [activeCaption, setActiveCaption] = React.useState(enCaptionIndex); const [isDebugVisible, setIsDebugVisible] = React.useState(false); const [isContextVisible, setIsContextVisible] = React.useState(false); const [contextX, setContextX] = React.useState(0); const [contextY, setContextY] = React.useState(0); const handleCaptionsButton = () => setActiveCaption((now) => ((now + 2) % (captions.length + 1)) - 1); const handleMuteUnmute = () => setAudioVolume((now) => (now === 0 ? 0.5 : 0)); const pingActivity = () => { setLastActive(new Date().getTime()); }; const updateBufferLength = () => { if (!refVideo.current || !lastMediaState.current) return; let maxVideo = 0; for (let i = 0; i < refVideo.current.buffered.length; i++) maxVideo = refVideo.current.buffered.start(i) <= lastMediaState.current.timeSeconds ? Math.max(maxVideo, refVideo.current.buffered.end(i)) : maxVideo; setBufferProgress(maxVideo); }; const [_redraw, _setRedraw] = React.useState(0); const redraw = () => _setRedraw((x) => ++x); const handleVideoTimeUpdate = () => { updateBufferLength(); props.onPlaybackProgress?.(lastMediaState.current.timeSeconds); redraw(); }; const handlePlayPause = () => { if (!refVideo.current) return; pingActivity(); if (lastMediaState.current.isPlaying) refMedia.current.pause(); else refMedia.current.play(); }; const handleMediaError = ( e: | React.SyntheticEvent | React.SyntheticEvent ) => { const error = // @ts-ignore e.nativeEvent.path?.[0]?.error || // @ts-ignore e.nativeEvent.originalTarget?.error || new Error("Unknown error"); refMedia.current.pause(); setIsVideoErrored(true); console.error("Error playing video: ", error.message); console.error(e); // Try to find out the error fetch(srcVideo) .then((res) => res.text()) .then((text) => { if (text.startsWith("{")) { const json = JSON.parse(text); setVideoErrorMessage( String( json.message || json.error.message || json.error.code || text ) ); } else setVideoErrorMessage(text); }) .catch((e) => console.error(e)); }; const nudgeTime = (delta: number) => { if (!refVideo.current) return 0; const newTime = Math.max( 0, Math.min(refVideo.current.currentTime + delta, refVideo.current.duration) ); refMedia.current?.seek(newTime); return newTime; }; /** * Screenshot current video frame and download it */ const captureFrame = () => { if (!refVideo.current) return; // Create canvas const canvas = document.createElement("canvas"); canvas.width = refVideo.current.videoWidth; canvas.height = refVideo.current.videoHeight; const ctx = canvas.getContext("2d"); // Draw video frame to canvas ctx.drawImage(refVideo.current, 0, 0, canvas.width, canvas.height); // Generate URI and download const uri = canvas.toDataURL("image/png"); const a = document.createElement("a"); a.href = uri; a.download = "Screenshot " + videoId + " " + Math.floor(refVideo.current.currentTime * 1000) + "ms.png"; a.click(); // Clean up canvas.remove(); a.remove(); }; const handleKeyDown = (e: React.KeyboardEvent) => { let handled = true; if (e.ctrlKey || e.altKey || e.shiftKey) return false; if (e.key === " " || e.key === "k") { handlePlayPause(); } else if (e.key === "ArrowRight") { nudgeTime(5); } else if (e.key === "ArrowLeft") { nudgeTime(-5); } else if (e.key === "l") { nudgeTime(10); } else if (e.key === "j") { nudgeTime(-10); } else if (e.key === "ArrowDown") { setAudioVolume((now) => Math.max(now - 0.1, 0)); } else if (e.key === "ArrowUp") { setAudioVolume((now) => Math.min(now + 0.1, 1)); } else if (e.key === "m") { handleMuteUnmute(); } else if (e.key === "f") { handleFullscreen(); } else if (e.key === "c") { handleCaptionsButton(); } else handled = false; if (handled) { pingActivity(); e.preventDefault(); } }; const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); const playerRect = e.currentTarget.getBoundingClientRect(); setContextX(e.clientX - playerRect.left); setContextY(e.clientY - playerRect.top); setIsContextVisible((now) => !now); }; const handleFullscreen = () => { if (!document.fullscreenElement) { refSelf.current?.requestFullscreen(); setIsFullscreen(true); } else { document.exitFullscreen(); setIsFullscreen(false); } }; const handleSeek = (val: number) => { pingActivity(); if (refMedia.current) refMedia.current.seek(val); }; React.useEffect(() => { if (!refVideo.current) return; refAudio.current.volume = audioVolume; }, [audioVolume]); React.useEffect(() => { if (props.autoplay) { checkAutoplay().then((can) => { if (can) refMedia.current?.play?.(); }); } }, []); const loadCaptions = async (url: string, format?: CaptionFormat) => { const fmt = format || detectFormat(url); await fetch(url) .then((res) => res.text()) .then((text) => { const xml = fmt === "srt" ? srtToSrv3(text) : text; setSrv3CaptionXMLs((now) => ({ ...now, [activeCaption]: xml })); }); }; React.useEffect(() => { if (activeCaption >= 0) loadCaptions(captions[activeCaption].src, captions[activeCaption].format); }, [activeCaption]); const controlsVisible = Date.now() - lastActive < 5000; return (
setLastActive(0)} onMouseMove={pingActivity} onKeyDown={handleKeyDown} onContextMenu={handleContextMenu} onBlur={() => setIsContextVisible(false)} tabIndex={0} > {isDebugVisible && (
{JSON.stringify( { ...(lastMediaState.current || {}), activeCaption }, null, 2 )}
)} {isContextVisible && ( <>
setIsContextVisible(false)} />
{ setIsDebugVisible(true); setIsContextVisible(false); }} > Show debug info
)}
{srcPoster && ( 0 && lastMediaState.current?.timeSeconds > 0 ? "opacity-0" : "z-10" } onClick={handlePlayPause} aria-hidden src={srcPoster} alt="Poster Image" layout="fill" /> )}
{isVideoErrored ? (

Error playing video

{videoErrorMessage}

) : ( )}
{showWatermark && ( )}
{ setAudioVolume(Number(e.target.value)); pingActivity(); }} />

{formatSeconds(lastMediaState.current?.timeSeconds || 0)} /{" "} {formatSeconds(lastMediaState.current?.duration || 0)}

{hasCaptions && ( )}
{activeCaption > -1 && srv3CaptionXMLs[activeCaption] && (
)} { lastMediaState.current = newState; if (activeCaption > -1) redraw(); }} >
); }; export default VideoPlayer2;