From 4e4916c81644c8845df3d93686d585f4ed99d15d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 21 Jun 2024 17:05:59 -0600 Subject: [PATCH] Make searching functional --- web/src/components/card/AnimatedEventCard.tsx | 2 +- .../components/filter/SearchFilterGroup.tsx | 1 - .../player/PreviewThumbnailPlayer.tsx | 458 +----------------- .../player/SearchThumbnailPlayer.tsx | 305 ++++++++++++ .../components/preview/ScrubbablePreview.tsx | 450 +++++++++++++++++ web/src/pages/Search.tsx | 63 ++- web/src/types/search.ts | 12 + 7 files changed, 830 insertions(+), 461 deletions(-) create mode 100644 web/src/components/player/SearchThumbnailPlayer.tsx create mode 100644 web/src/components/preview/ScrubbablePreview.tsx create mode 100644 web/src/types/search.ts diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 150613967..2adc27fb3 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -7,10 +7,10 @@ import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; import { RecordingStartingPoint } from "@/types/record"; import axios from "axios"; -import { VideoPreview } from "../player/PreviewThumbnailPlayer"; import { isCurrentHour } from "@/utils/dateUtil"; import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { baseUrl } from "@/api/baseUrl"; +import { VideoPreview } from "../preview/ScrubbablePreview"; type AnimatedEventCardProps = { event: ReviewSegment; diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index b48aef63e..8ec7cc329 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -28,7 +28,6 @@ type SearchFilterGroupProps = { filter?: ReviewFilter; filterList?: FilterList; onUpdateFilter: (filter: ReviewFilter) => void; - setMotionOnly: React.Dispatch>; }; export default function SearchFilterGroup({ diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 0859a0af9..c6173862f 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useApiHost } from "@/api"; import { isCurrentHour } from "@/utils/dateUtil"; import { ReviewSegment } from "@/types/review"; @@ -12,7 +6,7 @@ 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 { 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"; @@ -22,10 +16,9 @@ import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import useContextMenu from "@/hooks/use-contextmenu"; import ActivityIndicator from "../indicators/activity-indicator"; import { TimeRange } from "@/types/timeline"; -import { NoThumbSlider } from "../ui/slider"; -import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview"; import { capitalizeFirstLetter } from "@/utils/stringUtil"; -import { baseUrl } from "@/api/baseUrl"; +import { InProgressPreview, VideoPreview } from "../preview/ScrubbablePreview"; +import { Preview } from "@/types/preview"; type PreviewPlayerProps = { review: ReviewSegment; @@ -37,14 +30,6 @@ type PreviewPlayerProps = { onClick: (review: ReviewSegment, ctrl: boolean) => void; }; -type Preview = { - camera: string; - src: string; - type: string; - start: number; - end: number; -}; - export default function PreviewThumbnailPlayer({ review, allPreviews, @@ -341,438 +326,3 @@ function PreviewContent({ ); } } - -type VideoPreviewProps = { - relevantPreview: Preview; - startTime: number; - endTime?: number; - showProgress?: boolean; - loop?: boolean; - setReviewed: () => void; - setIgnoreClick: (ignore: boolean) => void; - isPlayingBack: (ended: boolean) => void; - onTimeUpdate?: (time: number | undefined) => void; - windowVisible: boolean; -}; -export function VideoPreview({ - relevantPreview, - startTime, - endTime, - showProgress = true, - loop = false, - setReviewed, - setIgnoreClick, - isPlayingBack, - onTimeUpdate, - windowVisible, -}: 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, startTime - relevantPreview.start - PREVIEW_PADDING); - - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const playerDuration = useMemo( - () => (endTime ?? relevantPreview.end) - startTime + 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 = PREVIEW_FPS; - } - - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playerRef]); - - // time progress update - - const onProgress = useCallback(() => { - if (!windowVisible) { - return; - } - - 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 && lastPercent < 50 && playerPercent > 50) { - setReviewed(); - } - - setLastPercent(playerPercent); - - if (playerPercent > 100) { - setReviewed(); - - if (loop && playerRef.current) { - if (manualPlayback) { - setManualPlayback(false); - setTimeout(() => setManualPlayback(true), 100); - } - - playerRef.current.currentTime = playerStartTime; - return; - } - - 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, windowVisible]); - - // 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; - } - }, 1000 / PREVIEW_FPS); - 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) { - 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); - setHoverTimeout(undefined); - - 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 ( -
- - {showProgress && ( - - )} -
- ); -} - -const MIN_LOAD_TIMEOUT_MS = 200; -type InProgressPreviewProps = { - review: ReviewSegment; - timeRange: TimeRange; - showProgress?: boolean; - loop?: boolean; - setReviewed: (reviewId: string) => void; - setIgnoreClick: (ignore: boolean) => void; - isPlayingBack: (ended: boolean) => void; - onTimeUpdate?: (time: number | undefined) => void; - windowVisible: boolean; -}; -export function InProgressPreview({ - review, - timeRange, - showProgress = true, - loop = false, - setReviewed, - setIgnoreClick, - isPlayingBack, - onTimeUpdate, - windowVisible, -}: 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 ?? timeRange.before) + PREVIEW_PADDING - }/frames`, - { revalidateOnFocus: false }, - ); - const [manualFrame, setManualFrame] = useState(false); - const [hoverTimeout, setHoverTimeout] = useState(); - const [key, setKey] = useState(0); - - const handleLoad = useCallback(() => { - if (!previewFrames || !windowVisible) { - 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 (loop) { - setKey(0); - return; - } - - if (isMobile) { - isPlayingBack(false); - - if (onTimeUpdate) { - onTimeUpdate(undefined); - } - } - - return; - } - - setTimeout(() => { - if (setReviewed && key == Math.floor(previewFrames.length / 2)) { - setReviewed(review.id); - } - - if (previewFrames[key + 1]) { - 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 ( -
- - {showProgress && ( - - )} -
- ); -} diff --git a/web/src/components/player/SearchThumbnailPlayer.tsx b/web/src/components/player/SearchThumbnailPlayer.tsx new file mode 100644 index 000000000..e2c4a9b63 --- /dev/null +++ b/web/src/components/player/SearchThumbnailPlayer.tsx @@ -0,0 +1,305 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useApiHost } from "@/api"; +import { isCurrentHour } from "@/utils/dateUtil"; +import { getIconForLabel } from "@/utils/iconUtil"; +import TimeAgo from "../dynamic/TimeAgo"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { 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 ActivityIndicator from "../indicators/activity-indicator"; +import { capitalizeFirstLetter } from "@/utils/stringUtil"; +import { VideoPreview } from "../preview/ScrubbablePreview"; +import { Preview } from "@/types/preview"; +import { SearchResult } from "@/types/search"; + +type SearchPlayerProps = { + searchResult: SearchResult; + allPreviews?: Preview[]; + scrollLock?: boolean; + onTimeUpdate?: (time: number | undefined) => void; + onClick: (searchResult: SearchResult, ctrl: boolean) => void; +}; + +export default function SearchThumbnailPlayer({ + searchResult, + allPreviews, + scrollLock = false, + onClick, + onTimeUpdate, +}: SearchPlayerProps) { + 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(searchResult, e.metaKey); + } + }, + [ignoreClick, searchResult, onClick], + ); + + const swipeHandlers = useSwipeable({ + onSwipedLeft: () => setPlayback(false), + onSwipedRight: () => setPlayback(true), + preventScrollOnSwipe: true, + }); + + // playback + + const relevantPreview = useMemo(() => { + if (!allPreviews) { + return undefined; + } + + let multiHour = false; + const firstIndex = Object.values(allPreviews).findIndex((preview) => { + if ( + preview.camera != searchResult.camera || + preview.end < searchResult.start_time + ) { + return false; + } + + if ((searchResult.end_time ?? Date.now() / 1000) > preview.end) { + multiHour = true; + } + + return true; + }); + + if (firstIndex == -1) { + return undefined; + } + + if (!multiHour) { + return allPreviews[firstIndex]; + } + + const firstPrev = allPreviews[firstIndex]; + const firstDuration = firstPrev.end - searchResult.start_time; + const secondDuration = + (searchResult.end_time ?? Date.now() / 1000) - 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 == searchResult.camera, + ); + } + + return undefined; + } + }, [allPreviews, searchResult]); + + // 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( + searchResult.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)} + > + +
+ { + <> + + {getIconForLabel( + searchResult.label, + "size-3 text-white", + )} + + + } +
+
+
+ + {[...new Set([searchResult.label])] + .filter( + (item) => item !== undefined && !item.includes("-verified"), + ) + .map((text) => capitalizeFirstLetter(text)) + .sort() + .join(", ") + .replaceAll("-verified", "")} + +
+
+ {!playingBack && ( + <> +
+
+
+ {searchResult.end_time ? ( + + ) : ( +
+ +
+ )} + {formattedDate} +
+
+ + )} +
+
+ ); +} + +type PreviewContentProps = { + searchResult: SearchResult; + relevantPreview: Preview | undefined; + setIgnoreClick: (ignore: boolean) => void; + isPlayingBack: (ended: boolean) => void; + onTimeUpdate?: (time: number | undefined) => void; +}; +function PreviewContent({ + searchResult, + relevantPreview, + setIgnoreClick, + isPlayingBack, + onTimeUpdate, +}: PreviewContentProps) { + // preview + + if (relevantPreview) { + return ( + {}} + /> + ); + } else if (isCurrentHour(searchResult.start_time)) { + return ( + /* { }} + />*/ +
+ ); + } +} diff --git a/web/src/components/preview/ScrubbablePreview.tsx b/web/src/components/preview/ScrubbablePreview.tsx new file mode 100644 index 000000000..fb4d79dfe --- /dev/null +++ b/web/src/components/preview/ScrubbablePreview.tsx @@ -0,0 +1,450 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useApiHost } from "@/api"; +import { ReviewSegment } from "@/types/review"; +import useSWR from "swr"; +import { isFirefox, isMobile, isSafari } from "react-device-detect"; +import { TimeRange } from "@/types/timeline"; +import { NoThumbSlider } from "../ui/slider"; +import { PREVIEW_FPS, PREVIEW_PADDING, Preview } from "@/types/preview"; +import { baseUrl } from "@/api/baseUrl"; + +type VideoPreviewProps = { + relevantPreview: Preview; + startTime: number; + endTime?: number; + showProgress?: boolean; + loop?: boolean; + setReviewed: () => void; + setIgnoreClick: (ignore: boolean) => void; + isPlayingBack: (ended: boolean) => void; + onTimeUpdate?: (time: number | undefined) => void; + windowVisible: boolean; +}; +export function VideoPreview({ + relevantPreview, + startTime, + endTime, + showProgress = true, + loop = false, + setReviewed, + setIgnoreClick, + isPlayingBack, + onTimeUpdate, + windowVisible, +}: 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, startTime - relevantPreview.start - PREVIEW_PADDING); + + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const playerDuration = useMemo( + () => (endTime ?? relevantPreview.end) - startTime + 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 = PREVIEW_FPS; + } + + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playerRef]); + + // time progress update + + const onProgress = useCallback(() => { + if (!windowVisible) { + return; + } + + 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 && lastPercent < 50 && playerPercent > 50) { + setReviewed(); + } + + setLastPercent(playerPercent); + + if (playerPercent > 100) { + setReviewed(); + + if (loop && playerRef.current) { + if (manualPlayback) { + setManualPlayback(false); + setTimeout(() => setManualPlayback(true), 100); + } + + playerRef.current.currentTime = playerStartTime; + return; + } + + 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, windowVisible]); + + // 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; + } + }, 1000 / PREVIEW_FPS); + 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) { + 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); + setHoverTimeout(undefined); + + 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 ( +
+ + {showProgress && ( + + )} +
+ ); +} + +const MIN_LOAD_TIMEOUT_MS = 200; +type InProgressPreviewProps = { + review: ReviewSegment; + timeRange: TimeRange; + showProgress?: boolean; + loop?: boolean; + setReviewed: (reviewId: string) => void; + setIgnoreClick: (ignore: boolean) => void; + isPlayingBack: (ended: boolean) => void; + onTimeUpdate?: (time: number | undefined) => void; + windowVisible: boolean; +}; +export function InProgressPreview({ + review, + timeRange, + showProgress = true, + loop = false, + setReviewed, + setIgnoreClick, + isPlayingBack, + onTimeUpdate, + windowVisible, +}: 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 ?? timeRange.before) + PREVIEW_PADDING + }/frames`, + { revalidateOnFocus: false }, + ); + const [manualFrame, setManualFrame] = useState(false); + const [hoverTimeout, setHoverTimeout] = useState(); + const [key, setKey] = useState(0); + + const handleLoad = useCallback(() => { + if (!previewFrames || !windowVisible) { + 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 (loop) { + setKey(0); + return; + } + + if (isMobile) { + isPlayingBack(false); + + if (onTimeUpdate) { + onTimeUpdate(undefined); + } + } + + return; + } + + setTimeout(() => { + if (setReviewed && key == Math.floor(previewFrames.length / 2)) { + setReviewed(review.id); + } + + if (previewFrames[key + 1]) { + 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 ( +
+ + {showProgress && ( + + )} +
+ ); +} diff --git a/web/src/pages/Search.tsx b/web/src/pages/Search.tsx index ec1d4e1ea..8b27f6e6a 100644 --- a/web/src/pages/Search.tsx +++ b/web/src/pages/Search.tsx @@ -1,12 +1,38 @@ import SearchFilterGroup from "@/components/filter/SearchFilterGroup"; +import SearchThumbnailPlayer from "@/components/player/SearchThumbnailPlayer"; import { Input } from "@/components/ui/input"; import { Toaster } from "@/components/ui/sonner"; -import { useState } from "react"; +import { SearchResult } from "@/types/search"; +import { useEffect, useState } from "react"; import { LuSearchCheck } from "react-icons/lu"; +import useSWR from "swr"; export default function Search() { + // search field handler + + const [searchTimeout, setSearchTimeout] = useState(); + const [search, setSearch] = useState(""); const [searchTerm, setSearchTerm] = useState(""); + // search api + + useEffect(() => { + if (searchTimeout) { + clearTimeout(searchTimeout); + } + + setSearchTimeout( + setTimeout(() => { + setSearchTimeout(undefined); + setSearchTerm(search); + }, 500), + ); + }, [search]); + + const { data: searchResults } = useSWR( + searchTerm.length > 0 ? ["events/search", { query: searchTerm }] : null, + ); + return (
@@ -15,11 +41,11 @@ export default function Search() { setSearchTerm(e.target.value)} - /> + value={search} + onChange={(e) => setSearch(e.target.value)} + /> - + {}} />
@@ -29,6 +55,33 @@ export default function Search() { Search For Detections
)} + + {searchResults && + searchResults.map((value) => { + const selected = false; + + return ( +
+
+ {}} + //onTimeUpdate={onPreviewTimeUpdate} + //onClick={onSelectReview} + /> +
+
+
+ ); + })}
); diff --git a/web/src/types/search.ts b/web/src/types/search.ts new file mode 100644 index 000000000..917c60432 --- /dev/null +++ b/web/src/types/search.ts @@ -0,0 +1,12 @@ +export type SearchResult = { + id: string; + camera: string; + description?: string; + start_time: number; + end_time?: number; + score: number; + label: string; + sub_label?: string; + thumb_path?: string; + zones: string[]; +};