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 && (
)}
{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;