mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-05-30 01:16:42 +02:00
Preview player upgrades (#10152)
* Implement manual slider control for previews * Automatically end preview video on mobile * Show current time on timeline * remove z height
This commit is contained in:
parent
cb30450060
commit
e7f8bca2c3
@ -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<FrigateConfig>("config");
|
||||
|
||||
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
|
||||
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({
|
||||
<PreviewContent
|
||||
review={review}
|
||||
relevantPreview={relevantPreview}
|
||||
setProgress={setProgress}
|
||||
setReviewed={handleSetReviewed}
|
||||
setIgnoreClick={setIgnoreClick}
|
||||
isPlayingBack={setPlayback}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -174,15 +181,6 @@ export default function PreviewThumbnailPlayer({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{playingBack && (
|
||||
<Slider
|
||||
className="absolute inset-x-0 bottom-0 z-10"
|
||||
value={[progress]}
|
||||
min={0}
|
||||
step={1}
|
||||
max={100}
|
||||
/>
|
||||
)}
|
||||
{!playingBack && imgLoaded && review.has_been_reviewed && (
|
||||
<div className="absolute inset-0 z-10 bg-black bg-opacity-60" />
|
||||
)}
|
||||
@ -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 (
|
||||
<VideoPreview
|
||||
review={review}
|
||||
relevantPreview={relevantPreview}
|
||||
setReviewed={setReviewed}
|
||||
setIgnoreClick={setIgnoreClick}
|
||||
isPlayingBack={isPlayingBack}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
/>
|
||||
);
|
||||
} else if (isCurrentHour(review.start_time)) {
|
||||
return (
|
||||
<InProgressPreview
|
||||
review={review}
|
||||
setReviewed={setReviewed}
|
||||
setIgnoreClick={setIgnoreClick}
|
||||
isPlayingBack={isPlayingBack}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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<HTMLVideoElement | null>(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 (
|
||||
<div className="relative size-full aspect-video bg-black">
|
||||
<video
|
||||
ref={playerRef}
|
||||
className="size-full aspect-video bg-black"
|
||||
@ -321,35 +424,41 @@ function PreviewContent({
|
||||
>
|
||||
<source src={relevantPreview.src} type={relevantPreview.type} />
|
||||
</video>
|
||||
);
|
||||
} else if (isCurrentHour(review.start_time)) {
|
||||
return (
|
||||
<InProgressPreview
|
||||
review={review}
|
||||
setProgress={setProgress}
|
||||
setReviewed={setReviewed}
|
||||
<Slider
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
value={[progress]}
|
||||
onValueChange={onManualSeek}
|
||||
onValueCommit={onStopManualSeek}
|
||||
min={0}
|
||||
step={1}
|
||||
max={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string[]>(
|
||||
`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 (
|
||||
<div className="size-full flex items-center bg-black">
|
||||
<div className="relative size-full flex items-center bg-black">
|
||||
<img
|
||||
className="size-full object-contain"
|
||||
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.jpg`}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
<Slider
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
value={[key]}
|
||||
onValueChange={onManualSeek}
|
||||
onValueCommit={onStopManualSeek}
|
||||
min={0}
|
||||
step={1}
|
||||
max={previewFrames.length - 1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
26
web/src/components/ui/slider-no-thumb.tsx
Normal file
26
web/src/components/ui/slider-no-thumb.tsx
Normal file
@ -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<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-blue-500" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-2 w-12 rounded-full border-2 border-transparent bg-transparent ring-offset-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
@ -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<typeof SliderPrimitive.Root>,
|
||||
@ -15,11 +15,12 @@ const Slider = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-blue-500" />
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider };
|
||||
export { Slider }
|
||||
|
@ -187,6 +187,10 @@ export default function EventView({
|
||||
return data;
|
||||
}, [minimap]);
|
||||
|
||||
// preview playback
|
||||
|
||||
const [previewTime, setPreviewTime] = useState<number>();
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
@ -287,6 +291,7 @@ export default function EventView({
|
||||
review={value}
|
||||
relevantPreview={relevantPreview}
|
||||
setReviewed={markItemAsReviewed}
|
||||
onTimeUpdate={setPreviewTime}
|
||||
onClick={onSelectReview}
|
||||
/>
|
||||
</div>
|
||||
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user