Files
frigate/web/src/components/player/HlsVideoPlayer.tsx
T

508 lines
14 KiB
TypeScript
Raw Normal View History

import {
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from "react";
2025-12-04 12:19:07 -06:00
import Hls, { HlsConfig } from "hls.js";
2025-11-20 15:58:58 -07:00
import { isDesktop, isMobile } from "react-device-detect";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
2024-03-22 10:56:53 -06:00
import VideoControls from "./VideoControls";
import { VideoResolutionType } from "@/types/live";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { AxiosResponse } from "axios";
import { toast } from "sonner";
2024-05-10 11:42:56 -06:00
import { useOverlayState } from "@/hooks/use-overlay-state";
import { useUserPersistence } from "@/hooks/use-user-persistence";
2024-05-21 04:56:17 +05:30
import { cn } from "@/lib/utils";
2024-07-17 07:39:37 -06:00
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
import { useTranslation } from "react-i18next";
2025-10-16 08:24:14 -05:00
import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay";
import { useIsAdmin } from "@/hooks/use-is-admin";
2024-04-01 08:20:27 -06:00
// Android native hls does not seek correctly
2025-11-20 15:58:58 -07:00
const USE_NATIVE_HLS = false;
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
const unsupportedErrorCodes = [
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
MediaError.MEDIA_ERR_DECODE,
];
export interface HlsSource {
playlist: string;
startPosition?: number;
}
type HlsVideoPlayerProps = {
videoRef: MutableRefObject<HTMLVideoElement | null>;
2024-07-17 07:39:37 -06:00
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
2024-03-22 10:56:53 -06:00
visible: boolean;
currentSource: HlsSource;
hotKeys: boolean;
supportsFullscreen: boolean;
fullscreen: boolean;
frigateControls?: boolean;
2025-05-18 17:23:01 -06:00
inpointOffset?: number;
onClipEnded?: (currentTime: number) => void;
onPlayerLoaded?: () => void;
onTimeUpdate?: (time: number) => void;
2024-03-15 06:52:38 -06:00
onPlaying?: () => void;
2025-10-16 15:15:23 -05:00
onSeekToTime?: (timestamp: number, play?: boolean) => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
toggleFullscreen?: () => void;
2024-07-17 07:39:37 -06:00
onError?: (error: RecordingPlayerError) => void;
2025-10-25 17:15:36 -05:00
isDetailMode?: boolean;
camera?: string;
currentTimeOverride?: number;
};
2025-10-25 17:15:36 -05:00
export default function HlsVideoPlayer({
videoRef,
2024-07-17 07:39:37 -06:00
containerRef,
2024-03-22 10:56:53 -06:00
visible,
currentSource,
hotKeys,
supportsFullscreen,
fullscreen,
frigateControls = true,
2025-05-18 17:23:01 -06:00
inpointOffset = 0,
onClipEnded,
onPlayerLoaded,
onTimeUpdate,
2024-03-15 06:52:38 -06:00
onPlaying,
2025-10-16 08:24:14 -05:00
onSeekToTime,
setFullResolution,
onUploadFrame,
toggleFullscreen,
2024-07-17 07:39:37 -06:00
onError,
2025-10-25 17:15:36 -05:00
isDetailMode = false,
camera,
currentTimeOverride,
}: HlsVideoPlayerProps) {
const { t } = useTranslation("components/player");
const { data: config } = useSWR<FrigateConfig>("config");
const isAdmin = useIsAdmin();
2025-10-18 13:19:21 -05:00
// for detail stream context in History
2025-10-25 17:15:36 -05:00
const currentTime = currentTimeOverride;
// playback
const hlsRef = useRef<Hls>();
const [useHlsCompat, setUseHlsCompat] = useState(false);
const [loadedMetadata, setLoadedMetadata] = useState(false);
2024-07-17 07:39:37 -06:00
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
2025-11-23 09:40:25 -06:00
const applyVideoDimensions = useCallback(
(width: number, height: number) => {
if (setFullResolution) {
setFullResolution({ width, height });
}
setVideoDimensions({ width, height });
if (height > 0) {
setTallCamera(width / height < ASPECT_VERTICAL_LAYOUT);
}
},
[setFullResolution],
);
const handleLoadedMetadata = useCallback(() => {
setLoadedMetadata(true);
2025-11-23 09:40:25 -06:00
if (!videoRef.current) {
return;
}
2025-11-23 09:40:25 -06:00
const width = videoRef.current.videoWidth;
const height = videoRef.current.videoHeight;
// iOS Safari occasionally reports 0x0 for videoWidth/videoHeight
// Poll with requestAnimationFrame until dimensions become available (or timeout).
if (width > 0 && height > 0) {
applyVideoDimensions(width, height);
return;
}
let attempts = 0;
const maxAttempts = 120; // ~2 seconds at 60fps
const tryGetDims = () => {
if (!videoRef.current) return;
const w = videoRef.current.videoWidth;
const h = videoRef.current.videoHeight;
if (w > 0 && h > 0) {
applyVideoDimensions(w, h);
return;
}
if (attempts < maxAttempts) {
attempts += 1;
requestAnimationFrame(tryGetDims);
}
};
requestAnimationFrame(tryGetDims);
}, [videoRef, applyVideoDimensions]);
useEffect(() => {
if (!videoRef.current) {
return;
}
2024-04-01 08:20:27 -06:00
if (USE_NATIVE_HLS && videoRef.current.canPlayType(HLS_MIME_TYPE)) {
return;
} else if (Hls.isSupported()) {
setUseHlsCompat(true);
}
}, [videoRef]);
useEffect(() => {
if (!videoRef.current) {
return;
}
2025-11-21 14:40:58 -07:00
setLoadedMetadata(false);
const currentPlaybackRate = videoRef.current.playbackRate;
if (!useHlsCompat) {
videoRef.current.src = currentSource.playlist;
videoRef.current.load();
return;
}
2025-12-04 12:19:07 -06:00
// Base HLS configuration
const hlsConfig: Partial<HlsConfig> = {
maxBufferLength: 10,
maxBufferSize: 20 * 1000 * 1000,
startPosition: currentSource.startPosition,
2025-12-04 12:19:07 -06:00
};
hlsRef.current = new Hls(hlsConfig);
hlsRef.current.attachMedia(videoRef.current);
hlsRef.current.loadSource(currentSource.playlist);
videoRef.current.playbackRate = currentPlaybackRate;
2025-08-25 12:33:17 -06:00
return () => {
// we must destroy the hlsRef every time the source changes
// so that we can create a new HLS instance with startPosition
// set at the optimal point in time
if (hlsRef.current) {
hlsRef.current.destroy();
}
};
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
2024-09-26 08:12:15 -06:00
// state handling
const onPlayPause = useCallback(
(play: boolean) => {
if (!videoRef.current) {
return;
}
if (play) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
},
[videoRef],
);
// controls
2024-05-21 04:56:17 +05:30
const [tallCamera, setTallCamera] = useState(false);
const [isPlaying, setIsPlaying] = useState(true);
const [muted, setMuted] = useUserPersistence("hlsPlayerMuted", true);
2024-05-10 11:42:56 -06:00
const [volume, setVolume] = useOverlayState("playerVolume", 1.0);
const [defaultPlaybackRate] = useUserPersistence("playbackRate", 1);
2024-05-14 07:38:03 -06:00
const [playbackRate, setPlaybackRate] = useOverlayState(
"playbackRate",
defaultPlaybackRate ?? 1,
);
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
const [controls, setControls] = useState(isMobile);
const [controlsOpen, setControlsOpen] = useState(false);
2024-09-27 07:41:48 -06:00
const [zoomScale, setZoomScale] = useState(1.0);
2025-10-16 08:24:14 -05:00
const [videoDimensions, setVideoDimensions] = useState<{
width: number;
height: number;
}>({ width: 0, height: 0 });
2024-04-14 10:14:10 -06: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]);
2025-05-18 17:23:01 -06:00
const getVideoTime = useCallback(() => {
const currentTime = videoRef.current?.currentTime;
if (!currentTime) {
return undefined;
}
return currentTime + inpointOffset;
}, [videoRef, inpointOffset]);
return (
2024-09-27 07:41:48 -06:00
<TransformWrapper
minScale={1.0}
wheel={{ smoothStep: 0.005 }}
onZoom={(zoom) => setZoomScale(zoom.state.scale)}
2024-10-25 07:24:04 -05:00
disabled={!frigateControls}
2024-09-27 07:41:48 -06:00
>
{frigateControls && (
<VideoControls
className={cn(
"absolute left-1/2 z-50 -translate-x-1/2",
tallCamera ? "bottom-12" : "bottom-5",
)}
video={videoRef.current}
isPlaying={isPlaying}
show={visible && (controls || controlsOpen)}
muted={muted}
volume={volume}
features={{
volume: true,
seek: true,
playbackRate: true,
plusUpload: isAdmin && config?.plus?.enabled == true,
fullscreen: supportsFullscreen,
}}
setControlsOpen={setControlsOpen}
setMuted={(muted) => setMuted(muted)}
playbackRate={playbackRate ?? 1}
hotKeys={hotKeys}
2024-09-26 08:12:15 -06:00
onPlayPause={onPlayPause}
onSeek={(diff) => {
2025-05-19 15:43:22 -05:00
const currentTime = videoRef.current?.currentTime;
if (!videoRef.current || !currentTime) {
return;
}
videoRef.current.currentTime = Math.max(0, currentTime + diff);
}}
onSetPlaybackRate={(rate) => {
setPlaybackRate(rate, true);
if (videoRef.current) {
videoRef.current.playbackRate = rate;
}
}}
onUploadFrame={async () => {
2025-05-18 17:23:01 -06:00
const frameTime = getVideoTime();
if (frameTime && onUploadFrame) {
const resp = await onUploadFrame(frameTime);
if (resp && resp.status == 200) {
toast.success(t("toast.success.submittedFrigatePlus"), {
position: "top-center",
});
} else {
toast.success(t("toast.error.submitFrigatePlusFailed"), {
position: "top-center",
});
}
}
}}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
containerRef={containerRef}
/>
)}
2024-04-10 07:40:17 -06:00
<TransformComponent
wrapperStyle={{
display: visible ? undefined : "none",
width: "100%",
height: "100%",
}}
2024-04-14 10:14:10 -06:00
wrapperProps={{
onClick: isDesktop ? undefined : () => setControls(!controls),
}}
2024-04-10 07:40:17 -06:00
contentStyle={{
width: "100%",
height: isMobile ? "100%" : undefined,
}}
2024-03-27 17:03:05 -06:00
>
2025-10-16 08:24:14 -05:00
{isDetailMode &&
camera &&
currentTime &&
2025-11-17 08:12:05 -06:00
loadedMetadata &&
2025-10-16 08:24:14 -05:00
videoDimensions.width > 0 &&
videoDimensions.height > 0 && (
2025-12-26 07:45:03 -07:00
<div
className={cn(
"absolute inset-0 z-50",
isDesktop
? "size-full"
: "mx-auto flex items-center justify-center portrait:max-h-[50dvh]",
)}
style={{
aspectRatio: `${videoDimensions.width} / ${videoDimensions.height}`,
}}
>
2025-10-16 08:24:14 -05:00
<ObjectTrackOverlay
2025-10-25 17:15:36 -05:00
key={`overlay-${currentTime}`}
2025-10-16 08:24:14 -05:00
camera={camera}
2025-10-16 15:15:23 -05:00
showBoundingBoxes={!isPlaying}
2025-10-16 08:24:14 -05:00
currentTime={currentTime}
videoWidth={videoDimensions.width}
videoHeight={videoDimensions.height}
className="absolute inset-0 z-10"
2025-10-16 15:15:23 -05:00
onSeekToTime={(timestamp, play) => {
2025-10-16 08:24:14 -05:00
if (onSeekToTime) {
2025-10-16 15:15:23 -05:00
onSeekToTime(timestamp, play);
2025-10-16 08:24:14 -05:00
}
}}
/>
</div>
)}
2024-04-10 07:40:17 -06:00
<video
ref={videoRef}
2024-09-26 08:12:15 -06:00
className={`size-full rounded-lg bg-black md:rounded-2xl ${loadedMetadata ? "" : "invisible"} cursor-pointer`}
2024-04-10 07:40:17 -06:00
preload="auto"
autoPlay
controls={!frigateControls}
2024-04-10 07:40:17 -06:00
playsInline
2024-04-14 10:14:10 -06:00
muted={muted}
2024-09-27 07:41:48 -06:00
onClick={
isDesktop
? () => {
if (zoomScale == 1.0) onPlayPause(!isPlaying);
}
: undefined
}
onVolumeChange={() => {
setVolume(videoRef.current?.volume ?? 1.0, true);
if (!frigateControls) {
setMuted(videoRef.current?.muted);
}
}}
2024-04-10 07:40:17 -06:00
onPlay={() => {
setIsPlaying(true);
2024-04-10 07:40:17 -06:00
if (isMobile) {
setControls(true);
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
2024-03-27 17:03:05 -06:00
}
}}
2024-04-10 07:40:17 -06:00
onPlaying={onPlaying}
onPause={() => {
setIsPlaying(false);
2024-07-17 07:39:37 -06:00
clearTimeout(bufferTimeout);
2024-03-22 10:56:53 -06:00
2024-04-10 07:40:17 -06:00
if (isMobile && mobileCtrlTimeout) {
clearTimeout(mobileCtrlTimeout);
2024-03-27 17:03:05 -06:00
}
}}
2024-07-17 07:39:37 -06:00
onWaiting={() => {
if (onError != undefined) {
if (videoRef.current?.paused) {
return;
}
setBufferTimeout(
setTimeout(() => {
if (
document.visibilityState === "visible" &&
videoRef.current
) {
onError("stalled");
}
}, 3000),
);
}
}}
onProgress={() => {
if (onError != undefined) {
if (videoRef.current?.paused) {
return;
}
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
}
}}
2025-05-18 17:23:01 -06:00
onTimeUpdate={() => {
if (!onTimeUpdate) {
return;
}
const frameTime = getVideoTime();
if (frameTime) {
onTimeUpdate(frameTime);
}
}}
onLoadedData={() => {
onPlayerLoaded?.();
2024-05-10 11:42:56 -06:00
handleLoadedMetadata();
2024-05-14 07:38:03 -06:00
if (videoRef.current) {
if (playbackRate) {
videoRef.current.playbackRate = playbackRate;
}
if (volume) {
videoRef.current.volume = volume;
}
2024-05-10 11:42:56 -06:00
}
}}
onEnded={() => {
if (onClipEnded) {
onClipEnded(getVideoTime() ?? 0);
}
}}
2024-04-10 07:40:17 -06:00
onError={(e) => {
if (
!hlsRef.current &&
// @ts-expect-error code does exist
unsupportedErrorCodes.includes(e.target.error.code) &&
videoRef.current
) {
setLoadedMetadata(false);
setUseHlsCompat(true);
2024-05-19 06:39:17 -06:00
} else {
toast.error(
// @ts-expect-error code does exist
`Failed to play recordings (error ${e.target.error.code}): ${e.target.error.message}`,
{
position: "top-center",
},
);
2024-04-10 07:40:17 -06:00
}
}}
2024-03-27 17:03:05 -06:00
/>
2024-04-10 07:40:17 -06:00
</TransformComponent>
2024-03-27 17:03:05 -06:00
</TransformWrapper>
);
}