import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useApiHost } from "@/api"; import { isCurrentHour } from "@/utils/dateUtil"; import { ReviewSegment } from "@/types/review"; import { Slider } from "../ui/slider-no-thumb"; import { getIconForLabel } from "@/utils/iconUtil"; import TimeAgo from "../dynamic/TimeAgo"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { isFirefox, isIOS, isMobile, isSafari } from "react-device-detect"; import Chip from "@/components/indicators/Chip"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useImageLoaded from "@/hooks/use-image-loaded"; import { useSwipeable } from "react-swipeable"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import useContextMenu from "@/hooks/use-contextmenu"; type PreviewPlayerProps = { review: ReviewSegment; allPreviews?: Preview[]; scrollLock?: boolean; onTimeUpdate?: (time: number | undefined) => void; setReviewed: (review: ReviewSegment) => void; onClick: (review: ReviewSegment, ctrl: boolean) => void; }; type Preview = { camera: string; src: string; type: string; start: number; end: number; }; export default function PreviewThumbnailPlayer({ review, allPreviews, scrollLock = false, setReviewed, onClick, onTimeUpdate, }: PreviewPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); // interaction const [ignoreClick, setIgnoreClick] = useState(false); const handleOnClick = useCallback( (e: React.MouseEvent) => { if (!ignoreClick) { onClick(review, e.metaKey); } }, [ignoreClick, review, onClick], ); const swipeHandlers = useSwipeable({ onSwipedLeft: () => (setReviewed ? setReviewed(review) : null), onSwipedRight: () => setPlayback(true), preventScrollOnSwipe: true, }); const handleSetReviewed = useCallback(() => { review.has_been_reviewed = true; setReviewed(review); }, [review, setReviewed]); useContextMenu(imgRef, () => { onClick(review, true); }); // playback const relevantPreview = useMemo(() => { if (!allPreviews) { return undefined; } let multiHour = false; const firstIndex = Object.values(allPreviews).findIndex((preview) => { if (preview.camera != review.camera || preview.end < review.start_time) { return false; } if (review.end_time > preview.end) { multiHour = true; } return true; }); if (firstIndex == -1) { return undefined; } if (!multiHour) { return allPreviews[firstIndex]; } const firstPrev = allPreviews[firstIndex]; const firstDuration = firstPrev.end - review.start_time; const secondDuration = review.end_time - firstPrev.end; if (firstDuration > secondDuration) { // the first preview is longer than the second, return the first return firstPrev; } else { // the second preview is longer, return the second if it exists if (firstIndex < allPreviews.length - 1) { return allPreviews.find( (preview, idx) => idx > firstIndex && preview.camera == review.camera, ); } return undefined; } }, [allPreviews, review]); // Hover Playback const [hoverTimeout, setHoverTimeout] = useState(); const [playback, setPlayback] = useState(false); const [tooltipHovering, setTooltipHovering] = useState(false); const playingBack = useMemo( () => playback && !tooltipHovering, [playback, tooltipHovering], ); const [isHovered, setIsHovered] = useState(false); useEffect(() => { if (isHovered && scrollLock) { return; } if (isHovered && !tooltipHovering) { setHoverTimeout( setTimeout(() => { setPlayback(true); setHoverTimeout(null); }, 500), ); } else { if (hoverTimeout) { clearTimeout(hoverTimeout); } setPlayback(false); if (onTimeUpdate) { onTimeUpdate(undefined); } } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [isHovered, scrollLock, tooltipHovering]); // date const formattedDate = useFormattedTimestamp( review.start_time, config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", ); return (
setIsHovered(true)} onMouseLeave={isMobile ? undefined : () => setIsHovered(false)} onClick={handleOnClick} {...swipeHandlers} > {playingBack && (
)}
{ onImgLoad(); }} />
setTooltipHovering(true)} onMouseLeave={() => setTooltipHovering(false)} >
{(review.severity == "alert" || review.severity == "detection") && ( <> {review.data.objects.map((object) => { return getIconForLabel(object, "size-3 text-white"); })} {review.data.audio.map((audio) => { return getIconForLabel(audio, "size-3 text-white"); })} )}
{[...(review.data.objects || []), ...(review.data.audio || [])] .filter((item) => item !== undefined) .join(", ") .replaceAll("-verified", "")}
{!playingBack && ( <>
{formattedDate}
)}
); } type PreviewContentProps = { review: ReviewSegment; relevantPreview: Preview | undefined; setReviewed: () => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; }; function PreviewContent({ review, relevantPreview, setReviewed, setIgnoreClick, isPlayingBack, onTimeUpdate, }: PreviewContentProps) { // preview if (relevantPreview) { return ( ); } else if (isCurrentHour(review.start_time)) { return ( ); } } const PREVIEW_PADDING = 16; type VideoPreviewProps = { review: ReviewSegment; relevantPreview: Preview; setReviewed: () => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; }; function VideoPreview({ review, relevantPreview, setReviewed, setIgnoreClick, isPlayingBack, onTimeUpdate, }: VideoPreviewProps) { const playerRef = useRef(null); const sliderRef = useRef(null); // keep track of playback state const [progress, setProgress] = useState(0); const [hoverTimeout, setHoverTimeout] = useState(); const playerStartTime = useMemo(() => { if (!relevantPreview) { return 0; } // start with a bit of padding return Math.max( 0, review.start_time - relevantPreview.start - PREVIEW_PADDING, ); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const playerDuration = useMemo( () => review.end_time - review.start_time + PREVIEW_PADDING, // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps [], ); const [lastPercent, setLastPercent] = useState(0.0); // initialize player correctly useEffect(() => { if (!playerRef.current) { return; } if (isSafari || (isFirefox && isMobile)) { playerRef.current.pause(); setManualPlayback(true); } else { playerRef.current.currentTime = playerStartTime; playerRef.current.playbackRate = 8; } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [playerRef]); // time progress update const onProgress = useCallback(() => { if (onTimeUpdate) { onTimeUpdate( relevantPreview.start + (playerRef.current?.currentTime || 0), ); } const playerProgress = (playerRef.current?.currentTime || 0) - playerStartTime; // end with a bit of padding const playerPercent = (playerProgress / playerDuration) * 100; if ( setReviewed && !review.has_been_reviewed && lastPercent < 50 && playerPercent > 50 ) { setReviewed(); } setLastPercent(playerPercent); if (playerPercent > 100) { if (!review.has_been_reviewed) { setReviewed(); } if (isMobile) { isPlayingBack(false); if (onTimeUpdate) { onTimeUpdate(undefined); } } else { playerRef.current?.pause(); } setManualPlayback(false); setProgress(100.0); } else { setProgress(playerPercent); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [setProgress, lastPercent]); // manual playback // safari is incapable of playing at a speed > 2x // so manual seeking is required on iOS const [manualPlayback, setManualPlayback] = useState(false); useEffect(() => { if (!manualPlayback || !playerRef.current) { return; } let counter = 0; const intervalId: NodeJS.Timeout = setInterval(() => { if (playerRef.current) { playerRef.current.currentTime = playerStartTime + counter; counter += 1; } }, 125); return () => clearInterval(intervalId); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [manualPlayback, playerRef]); // user interaction const onManualSeek = useCallback( (values: number[]) => { const value = values[0]; if (!playerRef.current) { return; } if (manualPlayback) { setManualPlayback(false); setIgnoreClick(true); } if (playerRef.current.paused == false) { playerRef.current.pause(); setIgnoreClick(true); } if (setReviewed && !review.has_been_reviewed) { setReviewed(); } setProgress(value); playerRef.current.currentTime = playerStartTime + (value / 100.0) * playerDuration; }, // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps [ manualPlayback, playerDuration, playerRef, playerStartTime, setIgnoreClick, ], ); const onStopManualSeek = useCallback(() => { setTimeout(() => { setIgnoreClick(false); if (isSafari || (isFirefox && isMobile)) { setManualPlayback(true); } else { playerRef.current?.play(); } }, 500); }, [playerRef, setIgnoreClick]); const onProgressHover = useCallback( (event: React.MouseEvent) => { if (!sliderRef.current) { return; } const rect = sliderRef.current.getBoundingClientRect(); const positionX = event.clientX - rect.left; const width = sliderRef.current.clientWidth; onManualSeek([Math.round((positionX / width) * 100)]); if (hoverTimeout) { clearTimeout(hoverTimeout); } setHoverTimeout(setTimeout(() => onStopManualSeek(), 500)); }, [sliderRef, hoverTimeout, onManualSeek, onStopManualSeek, setHoverTimeout], ); return (
); } const MIN_LOAD_TIMEOUT_MS = 200; type InProgressPreviewProps = { review: ReviewSegment; setReviewed: (reviewId: string) => void; setIgnoreClick: (ignore: boolean) => void; isPlayingBack: (ended: boolean) => void; onTimeUpdate?: (time: number | undefined) => void; }; function InProgressPreview({ review, setReviewed, setIgnoreClick, isPlayingBack, onTimeUpdate, }: InProgressPreviewProps) { const apiHost = useApiHost(); const sliderRef = useRef(null); const { data: previewFrames } = useSWR( `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${ Math.ceil(review.end_time) + PREVIEW_PADDING }/frames`, { revalidateOnFocus: false }, ); const [manualFrame, setManualFrame] = useState(false); const [hoverTimeout, setHoverTimeout] = useState(); const [key, setKey] = useState(0); const handleLoad = useCallback(() => { if (!previewFrames) { return; } if (onTimeUpdate) { onTimeUpdate(review.start_time - PREVIEW_PADDING + key); } if (manualFrame) { return; } if (key == previewFrames.length - 1) { if (!review.has_been_reviewed) { setReviewed(review.id); } if (isMobile) { isPlayingBack(false); if (onTimeUpdate) { onTimeUpdate(undefined); } } return; } setTimeout(() => { if (setReviewed && key == Math.floor(previewFrames.length / 2)) { setReviewed(review.id); } setKey(key + 1); }, MIN_LOAD_TIMEOUT_MS); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, manualFrame, previewFrames]); // user interaction const onManualSeek = useCallback( (values: number[]) => { const value = values[0]; if (!manualFrame) { setManualFrame(true); setIgnoreClick(true); } if (!review.has_been_reviewed) { setReviewed(review.id); } setKey(value); }, // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps [manualFrame, setIgnoreClick, setManualFrame, setKey], ); const onStopManualSeek = useCallback( (values: number[]) => { const value = values[0]; setTimeout(() => { setIgnoreClick(false); setManualFrame(false); setKey(value - 1); }, 500); }, [setManualFrame, setIgnoreClick], ); const onProgressHover = useCallback( (event: React.MouseEvent) => { if (!sliderRef.current || !previewFrames) { return; } const rect = sliderRef.current.getBoundingClientRect(); const positionX = event.clientX - rect.left; const width = sliderRef.current.clientWidth; const progress = [Math.round((positionX / width) * previewFrames.length)]; onManualSeek(progress); if (hoverTimeout) { clearTimeout(hoverTimeout); } setHoverTimeout(setTimeout(() => onStopManualSeek(progress), 500)); }, [ sliderRef, hoverTimeout, previewFrames, onManualSeek, onStopManualSeek, setHoverTimeout, ], ); if (!previewFrames || previewFrames.length == 0) { return ( ); } return (
); }