diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index a3d936e312..0c9af903fa 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -2,7 +2,7 @@ import { 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"; +import { Slider } from "../ui/slider-no-thumb"; import { getIconForLabel, getIconForSubLabel } from "@/utils/iconUtil"; import TimeAgo from "../dynamic/TimeAgo"; import useSWR from "swr"; @@ -29,6 +29,7 @@ type PreviewPlayerProps = { autoPlayback?: boolean; setReviewed?: (reviewId: string) => void; onClick?: (reviewId: string) => void; + onTimeUpdate?: (time: number | undefined) => void; }; type Preview = { @@ -44,25 +45,26 @@ export default function PreviewThumbnailPlayer({ relevantPreview, setReviewed, onClick, + onTimeUpdate, }: PreviewPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const [hoverTimeout, setHoverTimeout] = useState(); const [playback, setPlayback] = useState(false); - const [progress, setProgress] = useState(0); + const [ignoreClick, setIgnoreClick] = useState(false); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); // interaction const handleOnClick = useCallback(() => { - if (onClick) { + if (onClick && !ignoreClick) { onClick(review.id); } - }, [review, onClick]); + }, [ignoreClick, review, onClick]); const swipeHandlers = useSwipeable({ - onSwipedLeft: () => setPlayback(false), + onSwipedLeft: () => (setReviewed ? setReviewed(review.id) : null), onSwipedRight: () => setPlayback(true), preventScrollOnSwipe: true, }); @@ -92,7 +94,10 @@ export default function PreviewThumbnailPlayer({ } setPlayback(false); - setProgress(0); + + if (onTimeUpdate) { + onTimeUpdate(undefined); + } } }, @@ -123,8 +128,10 @@ export default function PreviewThumbnailPlayer({ )} @@ -174,15 +181,6 @@ export default function PreviewThumbnailPlayer({ )} - {playingBack && ( - - )} {!playingBack && imgLoaded && review.has_been_reviewed && (
)} @@ -193,23 +191,70 @@ export default function PreviewThumbnailPlayer({ ); } -const PREVIEW_PADDING = 16; type PreviewContentProps = { review: ReviewSegment; relevantPreview: Preview | undefined; - setProgress?: (progress: number) => void; setReviewed?: () => void; + setIgnoreClick: (ignore: boolean) => void; + isPlayingBack: (ended: boolean) => void; + onTimeUpdate?: (time: number | undefined) => void; }; function PreviewContent({ review, relevantPreview, - setProgress, 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); // keep track of playback state + const [progress, setProgress] = useState(0); const playerStartTime = useMemo(() => { if (!relevantPreview) { return 0; @@ -224,6 +269,12 @@ function PreviewContent({ // 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 @@ -248,16 +299,16 @@ function PreviewContent({ // time progress update const onProgress = useCallback(() => { - if (!setProgress) { - return; + if (onTimeUpdate) { + onTimeUpdate( + relevantPreview.start + (playerRef.current?.currentTime || 0), + ); } const playerProgress = (playerRef.current?.currentTime || 0) - playerStartTime; // end with a bit of padding - const playerDuration = - review.end_time - review.start_time + PREVIEW_PADDING; const playerPercent = (playerProgress / playerDuration) * 100; if ( @@ -272,7 +323,16 @@ function PreviewContent({ setLastPercent(playerPercent); if (playerPercent > 100) { - playerRef.current?.pause(); + if (isMobile) { + isPlayingBack(false); + + if (onTimeUpdate) { + onTimeUpdate(undefined); + } + } else { + playerRef.current?.pause(); + } + setManualPlayback(false); setProgress(100.0); } else { @@ -306,10 +366,53 @@ function PreviewContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [manualPlayback, playerRef]); - // preview + // user interaction - if (relevantPreview) { - return ( + 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); + } + + setProgress(value); + playerRef.current.currentTime = + playerStartTime + (value / 100.0) * playerDuration; + }, + [ + manualPlayback, + playerDuration, + playerRef, + playerStartTime, + setIgnoreClick, + ], + ); + + const onStopManualSeek = useCallback(() => { + setTimeout(() => { + setIgnoreClick(false); + + if (isSafari || (isFirefox && isMobile)) { + setManualPlayback(true); + } else { + playerRef.current?.play(); + } + }, 500); + }, [playerRef, setIgnoreClick]); + + return ( +
- ); - } else if (isCurrentHour(review.start_time)) { - return ( - - ); - } +
+ ); } const MIN_LOAD_TIMEOUT_MS = 200; type InProgressPreviewProps = { review: ReviewSegment; - setProgress?: (progress: number) => void; setReviewed?: (reviewId: string) => void; + setIgnoreClick: (ignore: boolean) => void; + isPlayingBack: (ended: boolean) => void; + onTimeUpdate?: (time: number | undefined) => void; }; function InProgressPreview({ review, - setProgress, setReviewed, + setIgnoreClick, + isPlayingBack, + onTimeUpdate, }: InProgressPreviewProps) { const apiHost = useApiHost(); const { data: previewFrames } = useSWR( - `preview/${review.camera}/start/${Math.floor(review.start_time) - 4}/end/${ - Math.ceil(review.end_time) + 4 + `preview/${review.camera}/start/${Math.floor(review.start_time) - PREVIEW_PADDING}/end/${ + Math.ceil(review.end_time) + PREVIEW_PADDING }/frames`, ); + const [manualFrame, setManualFrame] = useState(false); const [key, setKey] = useState(0); const handleLoad = useCallback(() => { @@ -357,19 +466,27 @@ function InProgressPreview({ return; } + if (onTimeUpdate) { + onTimeUpdate(review.start_time - PREVIEW_PADDING + key); + } + + if (manualFrame) { + return; + } + if (key == previewFrames.length - 1) { - if (setProgress) { - setProgress(100); + if (isMobile) { + isPlayingBack(false); + + if (onTimeUpdate) { + onTimeUpdate(undefined); + } } return; } setTimeout(() => { - if (setProgress) { - setProgress((key / (previewFrames.length - 1)) * 100); - } - if (setReviewed && key == Math.floor(previewFrames.length / 2)) { setReviewed(review.id); } @@ -379,7 +496,35 @@ function InProgressPreview({ // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, previewFrames]); + }, [key, manualFrame, previewFrames]); + + // user interaction + + const onManualSeek = useCallback( + (values: number[]) => { + const value = values[0]; + + if (!manualFrame) { + setManualFrame(true); + setIgnoreClick(true); + } + + setKey(value); + }, + [manualFrame, setIgnoreClick, setManualFrame, setKey], + ); + + const onStopManualSeek = useCallback( + (values: number[]) => { + const value = values[0]; + setTimeout(() => { + setIgnoreClick(false); + setManualFrame(false); + setKey(value - 1); + }, 500); + }, + [setManualFrame, setIgnoreClick], + ); if (!previewFrames || previewFrames.length == 0) { return ( @@ -391,12 +536,21 @@ function InProgressPreview({ } return ( -
+
+
); } diff --git a/web/src/components/ui/slider-no-thumb.tsx b/web/src/components/ui/slider-no-thumb.tsx new file mode 100644 index 0000000000..23f4d4ef11 --- /dev/null +++ b/web/src/components/ui/slider-no-thumb.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +import { cn } from "@/lib/utils"; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/web/src/components/ui/slider.tsx b/web/src/components/ui/slider.tsx index 2ed9fa7695..e161daec03 100644 --- a/web/src/components/ui/slider.tsx +++ b/web/src/components/ui/slider.tsx @@ -1,7 +1,7 @@ -import * as React from "react"; -import * as SliderPrimitive from "@radix-ui/react-slider"; +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" const Slider = React.forwardRef< React.ElementRef, @@ -15,11 +15,12 @@ const Slider = React.forwardRef< )} {...props} > - - + + + -)); -Slider.displayName = SliderPrimitive.Root.displayName; +)) +Slider.displayName = SliderPrimitive.Root.displayName -export { Slider }; +export { Slider } diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index e5199b4a48..8d7a129660 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -187,6 +187,10 @@ export default function EventView({ return data; }, [minimap]); + // preview playback + + const [previewTime, setPreviewTime] = useState(); + if (!config) { return ; } @@ -287,6 +291,7 @@ export default function EventView({ review={value} relevantPreview={relevantPreview} setReviewed={markItemAsReviewed} + onTimeUpdate={setPreviewTime} onClick={onSelectReview} />
@@ -308,6 +313,8 @@ export default function EventView({ showMinimap={showMinimap} minimapStartTime={minimapBounds.start} minimapEndTime={minimapBounds.end} + showHandlebar={previewTime != undefined} + handlebarTime={previewTime} events={reviewItems.all} severityType={severity} contentRef={contentRef}