mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 06:18:57 +03:00
Fix preview players at the hour rollover (#24157)
* fix preview players at the hour rollover * clean up
This commit is contained in:
committed by
Nicolas Mowen
parent
6ae8050974
commit
53b04f44ef
@@ -10,7 +10,7 @@ import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Preview } from "@/types/preview";
|
||||
import { PreviewPlayback } from "@/types/playback";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { isCurrentOrPreviousHour } from "@/utils/dateUtil";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { isAndroid, isChrome, isMobile } from "react-device-detect";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
@@ -76,7 +76,7 @@ export default function PreviewPlayer({
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentHour(timeRange.before)) {
|
||||
if (isCurrentOrPreviousHour(timeRange.before)) {
|
||||
return (
|
||||
<PreviewFramesPlayer
|
||||
className={className}
|
||||
@@ -345,7 +345,7 @@ function PreviewVideoPlayer({
|
||||
)}
|
||||
{cameraPreviews && !currentPreview && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl">
|
||||
{t("noPreviewFoundFor", { camera: cameraName })}
|
||||
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
||||
</div>
|
||||
)}
|
||||
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { isCurrentOrPreviousHour } from "@/utils/dateUtil";
|
||||
import {
|
||||
ReviewSegment,
|
||||
ThreatLevel,
|
||||
@@ -139,9 +139,19 @@ export default function PreviewThumbnailPlayer({
|
||||
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
|
||||
const [playback, setPlayback] = useState(false);
|
||||
const [tooltipHovering, setTooltipHovering] = useState(false);
|
||||
|
||||
const thumbnailUrl = `${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`;
|
||||
|
||||
// not memoized: depends on the wall clock, and a stale value blanks the card
|
||||
// for a whole hour after a rollover
|
||||
const hasPreviewContent =
|
||||
relevantPreview != undefined || isCurrentOrPreviousHour(review.start_time);
|
||||
|
||||
// playback hides the thumbnail below, and only a mounted player calls
|
||||
// isPlayingBack(false), so entering it empty leaves the card black
|
||||
const playingBack = useMemo(
|
||||
() => playback && !tooltipHovering,
|
||||
[playback, tooltipHovering],
|
||||
() => playback && !tooltipHovering && hasPreviewContent,
|
||||
[playback, tooltipHovering, hasPreviewContent],
|
||||
);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -208,6 +218,7 @@ export default function PreviewThumbnailPlayer({
|
||||
review={review}
|
||||
relevantPreview={relevantPreview}
|
||||
timeRange={timeRange}
|
||||
defaultImageUrl={thumbnailUrl}
|
||||
setReviewed={handleSetReviewed}
|
||||
setIgnoreClick={setIgnoreClick}
|
||||
isPlayingBack={setPlayback}
|
||||
@@ -234,7 +245,7 @@ export default function PreviewThumbnailPlayer({
|
||||
: undefined
|
||||
}
|
||||
draggable={false}
|
||||
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
||||
src={thumbnailUrl}
|
||||
loading={isSafari ? "eager" : "lazy"}
|
||||
onLoad={() => {
|
||||
onImgLoad();
|
||||
@@ -394,6 +405,7 @@ type PreviewContentProps = {
|
||||
review: ReviewSegment;
|
||||
relevantPreview: Preview | undefined;
|
||||
timeRange: TimeRange;
|
||||
defaultImageUrl: string;
|
||||
setReviewed: () => void;
|
||||
setIgnoreClick: (ignore: boolean) => void;
|
||||
isPlayingBack: (ended: boolean) => void;
|
||||
@@ -403,6 +415,7 @@ function PreviewContent({
|
||||
review,
|
||||
relevantPreview,
|
||||
timeRange,
|
||||
defaultImageUrl,
|
||||
setReviewed,
|
||||
setIgnoreClick,
|
||||
isPlayingBack,
|
||||
@@ -423,13 +436,14 @@ function PreviewContent({
|
||||
windowVisible={true}
|
||||
/>
|
||||
);
|
||||
} else if (isCurrentHour(review.start_time)) {
|
||||
} else if (isCurrentOrPreviousHour(review.start_time)) {
|
||||
return (
|
||||
<InProgressPreview
|
||||
camera={review.camera}
|
||||
startTime={review.start_time}
|
||||
endTime={review.end_time}
|
||||
timeRange={timeRange}
|
||||
defaultImageUrl={defaultImageUrl}
|
||||
setReviewed={setReviewed}
|
||||
setIgnoreClick={setIgnoreClick}
|
||||
isPlayingBack={isPlayingBack}
|
||||
@@ -438,4 +452,7 @@ function PreviewContent({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// unreachable while the caller gates on hasPreviewContent
|
||||
return <img className="size-full" src={defaultImageUrl} />;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Preview } from "@/types/preview";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useHourRollover } from "@/hooks/use-hour-rollover";
|
||||
|
||||
type OptionalCameraPreviewProps = {
|
||||
camera?: string;
|
||||
autoRefresh?: boolean;
|
||||
fetchPreviews?: boolean;
|
||||
refreshOnHourRollover?: boolean;
|
||||
};
|
||||
export function useCameraPreviews(
|
||||
initialTimeRange: TimeRange,
|
||||
@@ -14,6 +16,7 @@ export function useCameraPreviews(
|
||||
camera = "all",
|
||||
autoRefresh = true,
|
||||
fetchPreviews = true,
|
||||
refreshOnHourRollover = false,
|
||||
}: OptionalCameraPreviewProps,
|
||||
) {
|
||||
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
||||
@@ -22,33 +25,52 @@ export function useCameraPreviews(
|
||||
setTimeRange(initialTimeRange);
|
||||
}, [initialTimeRange]);
|
||||
|
||||
const { data: allPreviews } = useSWR<Preview[]>(
|
||||
const { data: allPreviews, mutate: refreshPreviews } = useSWR<Preview[]>(
|
||||
fetchPreviews
|
||||
? `preview/${camera}/start/${Math.round(timeRange.after)}/end/${Math.round(timeRange.before)}`
|
||||
: null,
|
||||
{ revalidateOnFocus: autoRefresh, revalidateOnReconnect: autoRefresh },
|
||||
);
|
||||
|
||||
// an hour's mp4 is written after that hour ends, so it is never in the
|
||||
// response the page loaded with
|
||||
useHourRollover(refreshPreviews, refreshOnHourRollover && fetchPreviews);
|
||||
|
||||
return fetchPreviews ? allPreviews : [];
|
||||
}
|
||||
|
||||
// we need to add a buffer of 5 seconds to the end preview times
|
||||
// this ensures that if preview generation is running slowly
|
||||
// and the previews are generated 1-5 seconds late
|
||||
// it is not falsely thrown out.
|
||||
const PREVIEW_END_BUFFER = 5; // seconds
|
||||
|
||||
export function getPreviewForTimeRange(
|
||||
allPreviews: Preview[],
|
||||
camera: string,
|
||||
timeRange: TimeRange,
|
||||
) {
|
||||
return allPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.ceil(preview.start) >= timeRange.after &&
|
||||
Math.floor(preview.end) <= timeRange.before + PREVIEW_END_BUFFER,
|
||||
);
|
||||
let best: Preview | undefined = undefined;
|
||||
let bestOverlap = 0;
|
||||
|
||||
for (const preview of allPreviews) {
|
||||
// a preview belongs to the hour it starts in. a camera whose feed drops
|
||||
// across the boundary produces one ending minutes into the next hour, and
|
||||
// without this it would claim that hour's slot as well.
|
||||
if (
|
||||
preview.camera != camera ||
|
||||
Math.ceil(preview.start) < timeRange.after
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// the slot for the live hour is stamped at page load and never grows, so
|
||||
// an hour-long preview never fits inside it. rank by overlap instead.
|
||||
const overlap =
|
||||
Math.min(preview.end, timeRange.before) -
|
||||
Math.max(preview.start, timeRange.after);
|
||||
|
||||
if (overlap > bestOverlap) {
|
||||
bestOverlap = overlap;
|
||||
best = preview;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export function usePreviewForTimeRange(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// the mp4 lands some way past :00 (first frame after the boundary, then an
|
||||
// encode), and background tabs clamp timers to ~1min, so ladder rather than
|
||||
// fire once.
|
||||
const RETRY_OFFSETS = [15, 45, 120, 300]; // seconds past the hour
|
||||
|
||||
/** Runs `callback` several times past each hour boundary; must be idempotent. */
|
||||
export function useHourRollover(callback: () => void, enabled: boolean = true) {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
const scheduleNextHour = () => {
|
||||
// every prior timeout has fired by the time this reschedules itself
|
||||
timeouts = [];
|
||||
|
||||
const now = Date.now();
|
||||
const nextHour = new Date(now);
|
||||
nextHour.setUTCMinutes(0, 0, 0);
|
||||
nextHour.setUTCHours(nextHour.getUTCHours() + 1);
|
||||
const msUntilBoundary = nextHour.getTime() - now;
|
||||
|
||||
RETRY_OFFSETS.forEach((offset) => {
|
||||
timeouts.push(
|
||||
setTimeout(
|
||||
() => callbackRef.current(),
|
||||
msUntilBoundary + offset * 1000,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// repeat the ladder next hour
|
||||
timeouts.push(
|
||||
setTimeout(
|
||||
scheduleNextHour,
|
||||
msUntilBoundary +
|
||||
(RETRY_OFFSETS[RETRY_OFFSETS.length - 1] + 1) * 1000,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
scheduleNextHour();
|
||||
|
||||
return () => timeouts.forEach(clearTimeout);
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -518,6 +518,7 @@ export default function Events() {
|
||||
previewTimes ?? { after: 0, before: 0 },
|
||||
{
|
||||
fetchPreviews: previewTimes != undefined,
|
||||
refreshOnHourRollover: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -385,6 +385,16 @@ export function isCurrentHour(timestamp: number) {
|
||||
return timestamp > now.getTime() / 1000;
|
||||
}
|
||||
|
||||
// a just-ended hour has no mp4 yet but may still have cached frames, so it
|
||||
// stays eligible for the frame-based players
|
||||
export function isCurrentOrPreviousHour(timestamp: number) {
|
||||
const previousHour = new Date();
|
||||
previousHour.setUTCMinutes(0, 0, 0);
|
||||
previousHour.setUTCHours(previousHour.getUTCHours() - 1);
|
||||
|
||||
return timestamp > previousHour.getTime() / 1000;
|
||||
}
|
||||
|
||||
export const convertLocalDateToTimestamp = (dateString: string): number => {
|
||||
// Ensure the date string is in the correct format (8 digits)
|
||||
if (!/^\d{8}$/.test(dateString)) {
|
||||
|
||||
@@ -153,6 +153,7 @@ export default function MotionSearchView({
|
||||
const allPreviews = useCameraPreviews(timeRange, {
|
||||
camera: selectedCamera ?? undefined,
|
||||
fetchPreviews: !isSearchDialogOpen,
|
||||
refreshOnHourRollover: true,
|
||||
});
|
||||
|
||||
// ROI state
|
||||
|
||||
Reference in New Issue
Block a user