mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
* Add basic search page * Abstract filters to separate components * Make searching functional * Add loading and no results indicators * Implement searching * Combine account and settings menus on mobile * Support using thumbnail for in progress detections * Fetch previews * Move recordings view and open recordings when search is selected * Implement detail pane * Implement saving of description * Implement similarity search * Fix clicking * Add date range picker * Fix * Fix iOS zoom bug * Mobile fixes * Use text area * Fix spacing for drawer * Fix fetching previews incorrectly
487 lines
13 KiB
TypeScript
487 lines
13 KiB
TypeScript
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 { TimelineScrubMode, 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<HTMLVideoElement | null>(null);
|
|
const sliderRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// keep track of playback state
|
|
|
|
const [progress, setProgress] = useState(0);
|
|
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout>();
|
|
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();
|
|
setPlaybackMode("compat");
|
|
} 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 (playbackMode != "auto") {
|
|
setPlaybackMode("auto");
|
|
setTimeout(() => setPlaybackMode("compat"), 100);
|
|
}
|
|
|
|
playerRef.current.currentTime = playerStartTime;
|
|
return;
|
|
}
|
|
|
|
if (isMobile) {
|
|
isPlayingBack(false);
|
|
|
|
if (onTimeUpdate) {
|
|
onTimeUpdate(undefined);
|
|
}
|
|
} else {
|
|
playerRef.current?.pause();
|
|
}
|
|
|
|
setPlaybackMode("auto");
|
|
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 [playbackMode, setPlaybackMode] = useState<TimelineScrubMode>("auto");
|
|
|
|
useEffect(() => {
|
|
if (playbackMode != "compat" || !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
|
|
}, [playbackMode, playerRef]);
|
|
|
|
// user interaction
|
|
|
|
useEffect(() => {
|
|
setIgnoreClick(playbackMode != "auto" && playbackMode != "compat");
|
|
}, [playbackMode, setIgnoreClick]);
|
|
|
|
const onManualSeek = useCallback(
|
|
(values: number[]) => {
|
|
const value = values[0];
|
|
|
|
if (!playerRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (playerRef.current.paused == false) {
|
|
playerRef.current.pause();
|
|
}
|
|
|
|
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
|
|
[playerDuration, playerRef, playerStartTime, setIgnoreClick],
|
|
);
|
|
|
|
const onStopManualSeek = useCallback(() => {
|
|
setTimeout(() => {
|
|
setHoverTimeout(undefined);
|
|
|
|
if (isSafari || (isFirefox && isMobile)) {
|
|
setPlaybackMode("compat");
|
|
} else {
|
|
setPlaybackMode("auto");
|
|
playerRef.current?.play();
|
|
}
|
|
}, 500);
|
|
}, [playerRef]);
|
|
|
|
const onProgressHover = useCallback(
|
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
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);
|
|
}
|
|
},
|
|
[sliderRef, hoverTimeout, onManualSeek],
|
|
);
|
|
|
|
return (
|
|
<div className="relative aspect-video size-full bg-black">
|
|
<video
|
|
ref={playerRef}
|
|
className="pointer-events-none aspect-video size-full bg-black"
|
|
autoPlay
|
|
playsInline
|
|
preload="auto"
|
|
muted
|
|
onTimeUpdate={onProgress}
|
|
>
|
|
<source
|
|
src={`${baseUrl}${relevantPreview.src.substring(1)}`}
|
|
type={relevantPreview.type}
|
|
/>
|
|
</video>
|
|
{showProgress && (
|
|
<NoThumbSlider
|
|
ref={sliderRef}
|
|
className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${playbackMode == "hover" || playbackMode == "drag" ? "h-4" : "h-2"}`}
|
|
value={[progress]}
|
|
onValueChange={(event) => {
|
|
setPlaybackMode("drag");
|
|
onManualSeek(event);
|
|
}}
|
|
onValueCommit={onStopManualSeek}
|
|
min={0}
|
|
step={1}
|
|
max={100}
|
|
onMouseMove={
|
|
isMobile
|
|
? undefined
|
|
: (event) => {
|
|
if (playbackMode != "drag") {
|
|
setPlaybackMode("hover");
|
|
onProgressHover(event);
|
|
}
|
|
}
|
|
}
|
|
onMouseLeave={
|
|
isMobile
|
|
? undefined
|
|
: () => {
|
|
if (!sliderRef.current) {
|
|
return;
|
|
}
|
|
|
|
setHoverTimeout(setTimeout(() => onStopManualSeek(), 500));
|
|
}
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement | null>(null);
|
|
const { data: previewFrames } = useSWR<string[]>(
|
|
`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 [playbackMode, setPlaybackMode] = useState<TimelineScrubMode>("auto");
|
|
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout>();
|
|
const [key, setKey] = useState(0);
|
|
|
|
const handleLoad = useCallback(() => {
|
|
if (!previewFrames || !windowVisible) {
|
|
return;
|
|
}
|
|
|
|
if (onTimeUpdate) {
|
|
onTimeUpdate(review.start_time - PREVIEW_PADDING + key);
|
|
}
|
|
|
|
if (playbackMode != "auto") {
|
|
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, playbackMode, previewFrames]);
|
|
|
|
// user interaction
|
|
|
|
useEffect(() => {
|
|
setIgnoreClick(playbackMode != "auto");
|
|
}, [playbackMode, setIgnoreClick]);
|
|
|
|
const onManualSeek = useCallback(
|
|
(values: number[]) => {
|
|
const value = values[0];
|
|
|
|
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
|
|
[setIgnoreClick, setKey],
|
|
);
|
|
|
|
const onStopManualSeek = useCallback(
|
|
(values: number[]) => {
|
|
const value = values[0];
|
|
setTimeout(() => {
|
|
setPlaybackMode("auto");
|
|
setKey(value - 1);
|
|
}, 500);
|
|
},
|
|
[setPlaybackMode],
|
|
);
|
|
|
|
const onProgressHover = useCallback(
|
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
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);
|
|
}
|
|
},
|
|
[sliderRef, hoverTimeout, previewFrames, onManualSeek],
|
|
);
|
|
|
|
if (!previewFrames || previewFrames.length == 0) {
|
|
return (
|
|
<img
|
|
className="size-full"
|
|
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative flex size-full items-center bg-black">
|
|
<img
|
|
className="pointer-events-none size-full object-contain"
|
|
src={`${apiHost}api/preview/${previewFrames[key]}/thumbnail.webp`}
|
|
onLoad={handleLoad}
|
|
/>
|
|
{showProgress && (
|
|
<NoThumbSlider
|
|
ref={sliderRef}
|
|
className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${playbackMode != "auto" ? "h-4" : "h-2"}`}
|
|
value={[key]}
|
|
onValueChange={(event) => {
|
|
setPlaybackMode("drag");
|
|
onManualSeek(event);
|
|
}}
|
|
onValueCommit={onStopManualSeek}
|
|
min={0}
|
|
step={1}
|
|
max={previewFrames.length - 1}
|
|
onMouseMove={
|
|
isMobile
|
|
? undefined
|
|
: (event) => {
|
|
if (playbackMode != "drag") {
|
|
setPlaybackMode("hover");
|
|
onProgressHover(event);
|
|
}
|
|
}
|
|
}
|
|
onMouseLeave={
|
|
isMobile
|
|
? undefined
|
|
: (event) => {
|
|
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),
|
|
];
|
|
|
|
setHoverTimeout(
|
|
setTimeout(() => onStopManualSeek(progress), 500),
|
|
);
|
|
}
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|