2024-04-30 14:52:56 +02:00
|
|
|
import {
|
|
|
|
MutableRefObject,
|
|
|
|
useCallback,
|
|
|
|
useEffect,
|
|
|
|
useRef,
|
|
|
|
useState,
|
|
|
|
} from "react";
|
2024-03-13 21:24:24 +01:00
|
|
|
import Hls from "hls.js";
|
2024-05-09 23:06:29 +02:00
|
|
|
import { isAndroid, isDesktop, isIOS, isMobile } from "react-device-detect";
|
2024-03-15 14:03:14 +01:00
|
|
|
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
2024-03-22 17:56:53 +01:00
|
|
|
import VideoControls from "./VideoControls";
|
2024-04-30 14:52:56 +02:00
|
|
|
import { VideoResolutionType } from "@/types/live";
|
2024-05-03 16:00:19 +02:00
|
|
|
import useSWR from "swr";
|
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
|
|
import { AxiosResponse } from "axios";
|
|
|
|
import { toast } from "sonner";
|
2024-05-10 19:42:56 +02:00
|
|
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
2024-05-14 15:38:03 +02:00
|
|
|
import { usePersistence } from "@/hooks/use-persistence";
|
2024-03-13 21:24:24 +01:00
|
|
|
|
2024-04-01 16:20:27 +02:00
|
|
|
// Android native hls does not seek correctly
|
|
|
|
const USE_NATIVE_HLS = !isAndroid;
|
2024-03-13 21:24:24 +01:00
|
|
|
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
|
|
|
|
const unsupportedErrorCodes = [
|
|
|
|
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
|
|
|
MediaError.MEDIA_ERR_DECODE,
|
|
|
|
];
|
|
|
|
|
|
|
|
type HlsVideoPlayerProps = {
|
|
|
|
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
2024-03-22 17:56:53 +01:00
|
|
|
visible: boolean;
|
2024-03-13 21:24:24 +01:00
|
|
|
currentSource: string;
|
2024-03-26 22:37:45 +01:00
|
|
|
hotKeys: boolean;
|
2024-05-09 23:06:29 +02:00
|
|
|
fullscreen: boolean;
|
2024-03-13 21:24:24 +01:00
|
|
|
onClipEnded?: () => void;
|
|
|
|
onPlayerLoaded?: () => void;
|
|
|
|
onTimeUpdate?: (time: number) => void;
|
2024-03-15 13:52:38 +01:00
|
|
|
onPlaying?: () => void;
|
2024-04-30 14:52:56 +02:00
|
|
|
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
2024-05-03 16:00:19 +02:00
|
|
|
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
2024-05-09 23:06:29 +02:00
|
|
|
setFullscreen?: (full: boolean) => void;
|
2024-03-13 21:24:24 +01:00
|
|
|
};
|
|
|
|
export default function HlsVideoPlayer({
|
|
|
|
videoRef,
|
2024-03-22 17:56:53 +01:00
|
|
|
visible,
|
2024-03-13 21:24:24 +01:00
|
|
|
currentSource,
|
2024-03-26 22:37:45 +01:00
|
|
|
hotKeys,
|
2024-05-09 23:06:29 +02:00
|
|
|
fullscreen,
|
2024-03-13 21:24:24 +01:00
|
|
|
onClipEnded,
|
|
|
|
onPlayerLoaded,
|
|
|
|
onTimeUpdate,
|
2024-03-15 13:52:38 +01:00
|
|
|
onPlaying,
|
2024-04-30 14:52:56 +02:00
|
|
|
setFullResolution,
|
2024-05-03 16:00:19 +02:00
|
|
|
onUploadFrame,
|
2024-05-09 23:06:29 +02:00
|
|
|
setFullscreen,
|
2024-03-13 21:24:24 +01:00
|
|
|
}: HlsVideoPlayerProps) {
|
2024-05-03 16:00:19 +02:00
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
2024-03-13 21:24:24 +01:00
|
|
|
// playback
|
|
|
|
|
|
|
|
const hlsRef = useRef<Hls>();
|
|
|
|
const [useHlsCompat, setUseHlsCompat] = useState(false);
|
2024-03-16 00:28:57 +01:00
|
|
|
const [loadedMetadata, setLoadedMetadata] = useState(false);
|
2024-03-13 21:24:24 +01:00
|
|
|
|
2024-04-30 14:52:56 +02:00
|
|
|
const handleLoadedMetadata = useCallback(() => {
|
|
|
|
setLoadedMetadata(true);
|
|
|
|
if (videoRef.current) {
|
|
|
|
if (setFullResolution) {
|
|
|
|
setFullResolution({
|
|
|
|
width: videoRef.current.videoWidth,
|
|
|
|
height: videoRef.current.videoHeight,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [videoRef, setFullResolution]);
|
|
|
|
|
2024-03-13 21:24:24 +01:00
|
|
|
useEffect(() => {
|
|
|
|
if (!videoRef.current) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2024-04-01 16:20:27 +02:00
|
|
|
if (USE_NATIVE_HLS && videoRef.current.canPlayType(HLS_MIME_TYPE)) {
|
2024-03-13 21:24:24 +01:00
|
|
|
return;
|
|
|
|
} else if (Hls.isSupported()) {
|
|
|
|
setUseHlsCompat(true);
|
|
|
|
}
|
|
|
|
}, [videoRef]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!videoRef.current) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const currentPlaybackRate = videoRef.current.playbackRate;
|
|
|
|
|
|
|
|
if (!useHlsCompat) {
|
|
|
|
videoRef.current.src = currentSource;
|
|
|
|
videoRef.current.load();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!hlsRef.current) {
|
|
|
|
hlsRef.current = new Hls();
|
|
|
|
hlsRef.current.attachMedia(videoRef.current);
|
|
|
|
}
|
|
|
|
|
|
|
|
hlsRef.current.loadSource(currentSource);
|
|
|
|
videoRef.current.playbackRate = currentPlaybackRate;
|
|
|
|
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
|
|
|
|
|
|
|
|
// controls
|
|
|
|
|
|
|
|
const [isPlaying, setIsPlaying] = useState(true);
|
2024-05-10 19:42:56 +02:00
|
|
|
const [muted, setMuted] = useOverlayState("playerMuted", true);
|
|
|
|
const [volume, setVolume] = useOverlayState("playerVolume", 1.0);
|
2024-05-14 15:38:03 +02:00
|
|
|
const [defaultPlaybackRate] = usePersistence("playbackRate", 1);
|
|
|
|
const [playbackRate, setPlaybackRate] = useOverlayState(
|
|
|
|
"playbackRate",
|
|
|
|
defaultPlaybackRate ?? 1,
|
|
|
|
);
|
2024-03-13 21:24:24 +01:00
|
|
|
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
|
|
|
|
const [controls, setControls] = useState(isMobile);
|
|
|
|
const [controlsOpen, setControlsOpen] = useState(false);
|
|
|
|
|
2024-04-14 18:14:10 +02:00
|
|
|
useEffect(() => {
|
|
|
|
if (!isDesktop) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const callback = (e: MouseEvent) => {
|
|
|
|
if (!videoRef.current) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const rect = videoRef.current.getBoundingClientRect();
|
|
|
|
|
|
|
|
if (
|
|
|
|
e.clientX > rect.left &&
|
|
|
|
e.clientX < rect.right &&
|
|
|
|
e.clientY > rect.top &&
|
|
|
|
e.clientY < rect.bottom
|
|
|
|
) {
|
|
|
|
setControls(true);
|
|
|
|
} else {
|
|
|
|
setControls(controlsOpen);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
window.addEventListener("mousemove", callback);
|
|
|
|
return () => {
|
|
|
|
window.removeEventListener("mousemove", callback);
|
|
|
|
};
|
|
|
|
}, [videoRef, controlsOpen]);
|
|
|
|
|
2024-03-13 21:24:24 +01:00
|
|
|
return (
|
2024-03-28 00:03:05 +01:00
|
|
|
<TransformWrapper minScale={1.0}>
|
2024-04-14 18:14:10 +02:00
|
|
|
<VideoControls
|
|
|
|
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-50"
|
|
|
|
video={videoRef.current}
|
|
|
|
isPlaying={isPlaying}
|
2024-05-03 16:00:19 +02:00
|
|
|
show={visible && (controls || controlsOpen)}
|
2024-04-14 18:14:10 +02:00
|
|
|
muted={muted}
|
|
|
|
volume={volume}
|
2024-05-03 16:00:19 +02:00
|
|
|
features={{
|
|
|
|
volume: true,
|
|
|
|
seek: true,
|
|
|
|
playbackRate: true,
|
|
|
|
plusUpload: config?.plus?.enabled == true,
|
2024-05-09 23:06:29 +02:00
|
|
|
fullscreen: !isIOS,
|
2024-05-03 16:00:19 +02:00
|
|
|
}}
|
2024-04-14 18:14:10 +02:00
|
|
|
setControlsOpen={setControlsOpen}
|
2024-05-10 19:42:56 +02:00
|
|
|
setMuted={(muted) => setMuted(muted, true)}
|
2024-05-14 15:38:03 +02:00
|
|
|
playbackRate={playbackRate ?? 1}
|
2024-04-14 18:14:10 +02:00
|
|
|
hotKeys={hotKeys}
|
|
|
|
onPlayPause={(play) => {
|
|
|
|
if (!videoRef.current) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (play) {
|
|
|
|
videoRef.current.play();
|
|
|
|
} else {
|
|
|
|
videoRef.current.pause();
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
onSeek={(diff) => {
|
|
|
|
const currentTime = videoRef.current?.currentTime;
|
|
|
|
|
|
|
|
if (!videoRef.current || !currentTime) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
videoRef.current.currentTime = Math.max(0, currentTime + diff);
|
|
|
|
}}
|
2024-05-14 15:38:03 +02:00
|
|
|
onSetPlaybackRate={(rate) => {
|
|
|
|
setPlaybackRate(rate);
|
|
|
|
|
|
|
|
if (videoRef.current) {
|
|
|
|
videoRef.current.playbackRate = rate;
|
|
|
|
}
|
|
|
|
}}
|
2024-05-03 16:00:19 +02:00
|
|
|
onUploadFrame={async () => {
|
|
|
|
if (videoRef.current && onUploadFrame) {
|
|
|
|
const resp = await onUploadFrame(videoRef.current.currentTime);
|
|
|
|
|
|
|
|
if (resp && resp.status == 200) {
|
2024-05-03 16:03:41 +02:00
|
|
|
toast.success("Successfully submitted frame to Frigate+", {
|
2024-05-03 16:00:19 +02:00
|
|
|
position: "top-center",
|
|
|
|
});
|
|
|
|
} else {
|
2024-05-03 16:03:41 +02:00
|
|
|
toast.success("Failed to submit frame to Frigate+", {
|
2024-05-03 16:00:19 +02:00
|
|
|
position: "top-center",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}}
|
2024-05-09 23:06:29 +02:00
|
|
|
fullscreen={fullscreen}
|
|
|
|
setFullscreen={setFullscreen}
|
2024-04-14 18:14:10 +02:00
|
|
|
/>
|
2024-04-10 15:40:17 +02:00
|
|
|
<TransformComponent
|
|
|
|
wrapperStyle={{
|
|
|
|
display: visible ? undefined : "none",
|
|
|
|
width: "100%",
|
|
|
|
height: "100%",
|
|
|
|
}}
|
2024-04-14 18:14:10 +02:00
|
|
|
wrapperProps={{
|
|
|
|
onClick: isDesktop ? undefined : () => setControls(!controls),
|
|
|
|
}}
|
2024-04-10 15:40:17 +02:00
|
|
|
contentStyle={{
|
|
|
|
width: "100%",
|
|
|
|
height: isMobile ? "100%" : undefined,
|
|
|
|
}}
|
2024-03-28 00:03:05 +01:00
|
|
|
>
|
2024-04-10 15:40:17 +02:00
|
|
|
<video
|
|
|
|
ref={videoRef}
|
2024-04-22 17:12:45 +02:00
|
|
|
className={`size-full bg-black rounded-lg md:rounded-2xl ${loadedMetadata ? "" : "invisible"}`}
|
2024-04-10 15:40:17 +02:00
|
|
|
preload="auto"
|
|
|
|
autoPlay
|
|
|
|
controls={false}
|
|
|
|
playsInline
|
2024-04-14 18:14:10 +02:00
|
|
|
muted={muted}
|
2024-05-10 19:42:56 +02:00
|
|
|
onVolumeChange={() =>
|
|
|
|
setVolume(videoRef.current?.volume ?? 1.0, true)
|
|
|
|
}
|
2024-04-10 15:40:17 +02:00
|
|
|
onPlay={() => {
|
|
|
|
setIsPlaying(true);
|
|
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
setControls(true);
|
|
|
|
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
|
2024-03-28 00:03:05 +01:00
|
|
|
}
|
|
|
|
}}
|
2024-04-10 15:40:17 +02:00
|
|
|
onPlaying={onPlaying}
|
|
|
|
onPause={() => {
|
|
|
|
setIsPlaying(false);
|
2024-03-14 00:13:52 +01:00
|
|
|
|
2024-04-10 15:40:17 +02:00
|
|
|
if (isMobile && mobileCtrlTimeout) {
|
|
|
|
clearTimeout(mobileCtrlTimeout);
|
2024-03-28 00:03:05 +01:00
|
|
|
}
|
|
|
|
}}
|
2024-04-10 15:40:17 +02:00
|
|
|
onTimeUpdate={() =>
|
|
|
|
onTimeUpdate && videoRef.current
|
|
|
|
? onTimeUpdate(videoRef.current.currentTime)
|
|
|
|
: undefined
|
2024-03-28 00:03:05 +01:00
|
|
|
}
|
2024-04-10 15:40:17 +02:00
|
|
|
onLoadedData={onPlayerLoaded}
|
2024-05-10 19:42:56 +02:00
|
|
|
onLoadedMetadata={() => {
|
|
|
|
handleLoadedMetadata();
|
|
|
|
|
2024-05-14 15:38:03 +02:00
|
|
|
if (videoRef.current) {
|
|
|
|
if (playbackRate) {
|
|
|
|
videoRef.current.playbackRate = playbackRate;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (volume) {
|
|
|
|
videoRef.current.volume = volume;
|
|
|
|
}
|
2024-05-10 19:42:56 +02:00
|
|
|
}
|
|
|
|
}}
|
2024-04-10 15:40:17 +02:00
|
|
|
onEnded={onClipEnded}
|
|
|
|
onError={(e) => {
|
|
|
|
if (
|
|
|
|
!hlsRef.current &&
|
|
|
|
// @ts-expect-error code does exist
|
|
|
|
unsupportedErrorCodes.includes(e.target.error.code) &&
|
|
|
|
videoRef.current
|
|
|
|
) {
|
|
|
|
setLoadedMetadata(false);
|
|
|
|
setUseHlsCompat(true);
|
|
|
|
}
|
|
|
|
}}
|
2024-03-28 00:03:05 +01:00
|
|
|
/>
|
2024-04-10 15:40:17 +02:00
|
|
|
</TransformComponent>
|
2024-03-28 00:03:05 +01:00
|
|
|
</TransformWrapper>
|
2024-03-13 21:24:24 +01:00
|
|
|
);
|
|
|
|
}
|