mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-29 19:36: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
@@ -26,7 +26,7 @@ function loadMockJson(filename: string): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1x1 transparent PNG
|
// 1x1 transparent PNG
|
||||||
const PLACEHOLDER_PNG = Buffer.from(
|
export const PLACEHOLDER_PNG = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
"base64",
|
"base64",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Preview hour rollover -- MEDIUM tier.
|
||||||
|
*
|
||||||
|
* Tiles beside the main player must not collapse to "No Preview Found" when
|
||||||
|
* the clock crosses an hour boundary with the page open: frames before, the
|
||||||
|
* hour's mp4 after. The mp4 src resolves to the /clips/** mock, which serves
|
||||||
|
* a PNG, so these assert the <source> is wired up, not that it decodes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "../fixtures/frigate-test";
|
||||||
|
import { PLACEHOLDER_PNG } from "../helpers/api-mocker";
|
||||||
|
|
||||||
|
const HOUR_START = Date.UTC(2026, 8, 1, 10, 0, 0) / 1000;
|
||||||
|
const HOUR_END = Date.UTC(2026, 8, 1, 11, 0, 0) / 1000;
|
||||||
|
const REVIEW_START = HOUR_START + 900; // 10:15, inside the slot under test
|
||||||
|
|
||||||
|
// close to the boundary so fastForward skips few unrelated app timers
|
||||||
|
const JUST_BEFORE_ROLLOVER = new Date(Date.UTC(2026, 8, 1, 10, 59, 50));
|
||||||
|
|
||||||
|
// the main camera's scrub preview plus one tile per other camera
|
||||||
|
const CAMERAS = ["front_door", "backyard", "garage"];
|
||||||
|
|
||||||
|
const review = {
|
||||||
|
id: "review-rollover-001",
|
||||||
|
camera: "front_door",
|
||||||
|
start_time: REVIEW_START,
|
||||||
|
end_time: REVIEW_START + 30,
|
||||||
|
has_been_reviewed: false,
|
||||||
|
severity: "alert",
|
||||||
|
thumb_path: "/clips/front_door/review-rollover-001-thumb.jpg",
|
||||||
|
data: {
|
||||||
|
audio: [],
|
||||||
|
detections: ["person-abc123"],
|
||||||
|
objects: ["person"],
|
||||||
|
sub_labels: [],
|
||||||
|
significant_motion_areas: [],
|
||||||
|
zones: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function previewFor(camera: string) {
|
||||||
|
return {
|
||||||
|
camera,
|
||||||
|
src: `/clips/previews/${camera}/${HOUR_START}-${HOUR_END}.mp4`,
|
||||||
|
type: "video/mp4",
|
||||||
|
start: HOUR_START,
|
||||||
|
end: HOUR_END + 0.4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens RecordingView pre-boundary; returns a fn that rolls the hour over. */
|
||||||
|
async function openAtRollover(frigateApp: {
|
||||||
|
page: import("@playwright/test").Page;
|
||||||
|
installDefaults: (o?: { reviews?: unknown[] }) => Promise<void>;
|
||||||
|
goto: (p: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
await frigateApp.page.clock.install({ time: JUST_BEFORE_ROLLOVER });
|
||||||
|
await frigateApp.installDefaults({ reviews: [review] });
|
||||||
|
|
||||||
|
let hourRolled = false;
|
||||||
|
|
||||||
|
await frigateApp.page.route("**/api/review/review-rollover-001", (route) =>
|
||||||
|
route.fulfill({ json: review }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.page.route(/\/api\/preview\/.+\/start\//, (route) => {
|
||||||
|
if (route.request().url().includes("/frames")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: hourRolled ? [] : [`preview_backyard-${REVIEW_START}.webp`],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({
|
||||||
|
json: hourRolled ? CAMERAS.map(previewFor) : [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await frigateApp.page.route("**/thumbnail.webp", (route) =>
|
||||||
|
route.fulfill({ contentType: "image/png", body: PLACEHOLDER_PNG }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.goto("/review?id=review-rollover-001");
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
hourRolled = true;
|
||||||
|
await frigateApp.page.clock.fastForward("00:40");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Preview hour rollover: desktop @medium", () => {
|
||||||
|
// one alternation regex, not two entries: Playwright's isFixtureTuple reads a
|
||||||
|
// two-element array as [value, options]
|
||||||
|
test.use({
|
||||||
|
expectedErrors: [/no supported source was found|MEDIA_ELEMENT_ERROR/i],
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tile keeps a preview source when the hour rolls over", async ({
|
||||||
|
frigateApp,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name !== "desktop",
|
||||||
|
"preview tile row only renders on desktop",
|
||||||
|
);
|
||||||
|
|
||||||
|
const rollOver = await openAtRollover(frigateApp);
|
||||||
|
|
||||||
|
// both handles are URLs only PreviewPlayer renders: the main camera's
|
||||||
|
// scrub preview and one tile per other camera, all hit by the same bug
|
||||||
|
const frameTiles = frigateApp.page.locator('img[src*="/thumbnail.webp"]');
|
||||||
|
const mp4Tiles = frigateApp.page.locator(
|
||||||
|
`video source[src*="${HOUR_START}-${HOUR_END}"]`,
|
||||||
|
);
|
||||||
|
const barePanel = frigateApp.page.getByText("No Preview Found", {
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(frameTiles).toHaveCount(CAMERAS.length, { timeout: 15_000 });
|
||||||
|
await expect(barePanel).toHaveCount(0);
|
||||||
|
|
||||||
|
await rollOver();
|
||||||
|
|
||||||
|
await expect(mp4Tiles).toHaveCount(CAMERAS.length, { timeout: 15_000 });
|
||||||
|
await expect(barePanel).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Preview hour rollover: mobile @medium @mobile", () => {
|
||||||
|
test.use({
|
||||||
|
expectedErrors: [/no supported source was found|MEDIA_ELEMENT_ERROR/i],
|
||||||
|
});
|
||||||
|
|
||||||
|
test("main camera scrub preview keeps its source when the hour rolls over", async ({
|
||||||
|
frigateApp,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name === "desktop",
|
||||||
|
"desktop case above covers the tile row as well",
|
||||||
|
);
|
||||||
|
|
||||||
|
const rollOver = await openAtRollover(frigateApp);
|
||||||
|
|
||||||
|
// the tile row is desktop-only, but the main player's scrub preview is a
|
||||||
|
// PreviewPlayer too, so mobile has exactly one and it hits the same bug
|
||||||
|
const frameTiles = frigateApp.page.locator('img[src*="/thumbnail.webp"]');
|
||||||
|
const mp4Tiles = frigateApp.page.locator(
|
||||||
|
`video source[src*="${HOUR_START}-${HOUR_END}"]`,
|
||||||
|
);
|
||||||
|
const barePanel = frigateApp.page.getByText("No Preview Found", {
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(frameTiles).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(barePanel).toHaveCount(0);
|
||||||
|
|
||||||
|
await rollOver();
|
||||||
|
|
||||||
|
await expect(mp4Tiles).toHaveCount(1, { timeout: 15_000 });
|
||||||
|
await expect(barePanel).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A review with no mp4 at all (aged out, camera offline, generation failed)
|
||||||
|
* used to fade to black on hover, since playback hides the thumbnail.
|
||||||
|
*/
|
||||||
|
test.describe("Review card without a preview: desktop @medium", () => {
|
||||||
|
test("hovering keeps the thumbnail instead of blanking the card", async ({
|
||||||
|
frigateApp,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== "desktop", "hover is desktop only");
|
||||||
|
|
||||||
|
// three hours old, so neither the current nor the previous hour
|
||||||
|
const start = Math.floor(Date.now() / 1000) - 3 * 3600;
|
||||||
|
const noPreviewReview = {
|
||||||
|
...review,
|
||||||
|
id: "review-noprev-001",
|
||||||
|
start_time: start,
|
||||||
|
end_time: start + 30,
|
||||||
|
thumb_path: "/clips/front_door/review-noprev-001-thumb.jpg",
|
||||||
|
};
|
||||||
|
|
||||||
|
await frigateApp.installDefaults({ reviews: [noPreviewReview] });
|
||||||
|
await frigateApp.goto("/review");
|
||||||
|
|
||||||
|
const thumbnail = frigateApp.page.locator(
|
||||||
|
'img[src*="review-noprev-001-thumb"]',
|
||||||
|
);
|
||||||
|
await expect(thumbnail).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await thumbnail.hover();
|
||||||
|
// the card waits 500ms before entering playback
|
||||||
|
await expect(thumbnail).toHaveCSS("opacity", "1", { timeout: 3_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@ import useSWR from "swr";
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { Preview } from "@/types/preview";
|
import { Preview } from "@/types/preview";
|
||||||
import { PreviewPlayback } from "@/types/playback";
|
import { PreviewPlayback } from "@/types/playback";
|
||||||
import { isCurrentHour } from "@/utils/dateUtil";
|
import { isCurrentOrPreviousHour } from "@/utils/dateUtil";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { isAndroid, isChrome, isMobile } from "react-device-detect";
|
import { isAndroid, isChrome, isMobile } from "react-device-detect";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
@@ -76,7 +76,7 @@ export default function PreviewPlayer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCurrentHour(timeRange.before)) {
|
if (isCurrentOrPreviousHour(timeRange.before)) {
|
||||||
return (
|
return (
|
||||||
<PreviewFramesPlayer
|
<PreviewFramesPlayer
|
||||||
className={className}
|
className={className}
|
||||||
@@ -345,7 +345,7 @@ function PreviewVideoPlayer({
|
|||||||
)}
|
)}
|
||||||
{cameraPreviews && !currentPreview && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { isCurrentHour } from "@/utils/dateUtil";
|
import { isCurrentOrPreviousHour } from "@/utils/dateUtil";
|
||||||
import {
|
import {
|
||||||
ReviewSegment,
|
ReviewSegment,
|
||||||
ThreatLevel,
|
ThreatLevel,
|
||||||
@@ -139,9 +139,19 @@ export default function PreviewThumbnailPlayer({
|
|||||||
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
|
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>();
|
||||||
const [playback, setPlayback] = useState(false);
|
const [playback, setPlayback] = useState(false);
|
||||||
const [tooltipHovering, setTooltipHovering] = 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(
|
const playingBack = useMemo(
|
||||||
() => playback && !tooltipHovering,
|
() => playback && !tooltipHovering && hasPreviewContent,
|
||||||
[playback, tooltipHovering],
|
[playback, tooltipHovering, hasPreviewContent],
|
||||||
);
|
);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
@@ -208,6 +218,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
review={review}
|
review={review}
|
||||||
relevantPreview={relevantPreview}
|
relevantPreview={relevantPreview}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
|
defaultImageUrl={thumbnailUrl}
|
||||||
setReviewed={handleSetReviewed}
|
setReviewed={handleSetReviewed}
|
||||||
setIgnoreClick={setIgnoreClick}
|
setIgnoreClick={setIgnoreClick}
|
||||||
isPlayingBack={setPlayback}
|
isPlayingBack={setPlayback}
|
||||||
@@ -234,7 +245,7 @@ export default function PreviewThumbnailPlayer({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
src={`${apiHost}${review.thumb_path.replace("/media/frigate/", "")}`}
|
src={thumbnailUrl}
|
||||||
loading={isSafari ? "eager" : "lazy"}
|
loading={isSafari ? "eager" : "lazy"}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
onImgLoad();
|
onImgLoad();
|
||||||
@@ -394,6 +405,7 @@ type PreviewContentProps = {
|
|||||||
review: ReviewSegment;
|
review: ReviewSegment;
|
||||||
relevantPreview: Preview | undefined;
|
relevantPreview: Preview | undefined;
|
||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
|
defaultImageUrl: string;
|
||||||
setReviewed: () => void;
|
setReviewed: () => void;
|
||||||
setIgnoreClick: (ignore: boolean) => void;
|
setIgnoreClick: (ignore: boolean) => void;
|
||||||
isPlayingBack: (ended: boolean) => void;
|
isPlayingBack: (ended: boolean) => void;
|
||||||
@@ -403,6 +415,7 @@ function PreviewContent({
|
|||||||
review,
|
review,
|
||||||
relevantPreview,
|
relevantPreview,
|
||||||
timeRange,
|
timeRange,
|
||||||
|
defaultImageUrl,
|
||||||
setReviewed,
|
setReviewed,
|
||||||
setIgnoreClick,
|
setIgnoreClick,
|
||||||
isPlayingBack,
|
isPlayingBack,
|
||||||
@@ -423,13 +436,14 @@ function PreviewContent({
|
|||||||
windowVisible={true}
|
windowVisible={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (isCurrentHour(review.start_time)) {
|
} else if (isCurrentOrPreviousHour(review.start_time)) {
|
||||||
return (
|
return (
|
||||||
<InProgressPreview
|
<InProgressPreview
|
||||||
camera={review.camera}
|
camera={review.camera}
|
||||||
startTime={review.start_time}
|
startTime={review.start_time}
|
||||||
endTime={review.end_time}
|
endTime={review.end_time}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
|
defaultImageUrl={defaultImageUrl}
|
||||||
setReviewed={setReviewed}
|
setReviewed={setReviewed}
|
||||||
setIgnoreClick={setIgnoreClick}
|
setIgnoreClick={setIgnoreClick}
|
||||||
isPlayingBack={isPlayingBack}
|
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 { TimeRange } from "@/types/timeline";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { useHourRollover } from "@/hooks/use-hour-rollover";
|
||||||
|
|
||||||
type OptionalCameraPreviewProps = {
|
type OptionalCameraPreviewProps = {
|
||||||
camera?: string;
|
camera?: string;
|
||||||
autoRefresh?: boolean;
|
autoRefresh?: boolean;
|
||||||
fetchPreviews?: boolean;
|
fetchPreviews?: boolean;
|
||||||
|
refreshOnHourRollover?: boolean;
|
||||||
};
|
};
|
||||||
export function useCameraPreviews(
|
export function useCameraPreviews(
|
||||||
initialTimeRange: TimeRange,
|
initialTimeRange: TimeRange,
|
||||||
@@ -14,6 +16,7 @@ export function useCameraPreviews(
|
|||||||
camera = "all",
|
camera = "all",
|
||||||
autoRefresh = true,
|
autoRefresh = true,
|
||||||
fetchPreviews = true,
|
fetchPreviews = true,
|
||||||
|
refreshOnHourRollover = false,
|
||||||
}: OptionalCameraPreviewProps,
|
}: OptionalCameraPreviewProps,
|
||||||
) {
|
) {
|
||||||
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
const [timeRange, setTimeRange] = useState(initialTimeRange);
|
||||||
@@ -22,33 +25,52 @@ export function useCameraPreviews(
|
|||||||
setTimeRange(initialTimeRange);
|
setTimeRange(initialTimeRange);
|
||||||
}, [initialTimeRange]);
|
}, [initialTimeRange]);
|
||||||
|
|
||||||
const { data: allPreviews } = useSWR<Preview[]>(
|
const { data: allPreviews, mutate: refreshPreviews } = useSWR<Preview[]>(
|
||||||
fetchPreviews
|
fetchPreviews
|
||||||
? `preview/${camera}/start/${Math.round(timeRange.after)}/end/${Math.round(timeRange.before)}`
|
? `preview/${camera}/start/${Math.round(timeRange.after)}/end/${Math.round(timeRange.before)}`
|
||||||
: null,
|
: null,
|
||||||
{ revalidateOnFocus: autoRefresh, revalidateOnReconnect: autoRefresh },
|
{ 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 : [];
|
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(
|
export function getPreviewForTimeRange(
|
||||||
allPreviews: Preview[],
|
allPreviews: Preview[],
|
||||||
camera: string,
|
camera: string,
|
||||||
timeRange: TimeRange,
|
timeRange: TimeRange,
|
||||||
) {
|
) {
|
||||||
return allPreviews.find(
|
let best: Preview | undefined = undefined;
|
||||||
(preview) =>
|
let bestOverlap = 0;
|
||||||
preview.camera == camera &&
|
|
||||||
Math.ceil(preview.start) >= timeRange.after &&
|
for (const preview of allPreviews) {
|
||||||
Math.floor(preview.end) <= timeRange.before + PREVIEW_END_BUFFER,
|
// 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(
|
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 },
|
previewTimes ?? { after: 0, before: 0 },
|
||||||
{
|
{
|
||||||
fetchPreviews: previewTimes != undefined,
|
fetchPreviews: previewTimes != undefined,
|
||||||
|
refreshOnHourRollover: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -385,6 +385,16 @@ export function isCurrentHour(timestamp: number) {
|
|||||||
return timestamp > now.getTime() / 1000;
|
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 => {
|
export const convertLocalDateToTimestamp = (dateString: string): number => {
|
||||||
// Ensure the date string is in the correct format (8 digits)
|
// Ensure the date string is in the correct format (8 digits)
|
||||||
if (!/^\d{8}$/.test(dateString)) {
|
if (!/^\d{8}$/.test(dateString)) {
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export default function MotionSearchView({
|
|||||||
const allPreviews = useCameraPreviews(timeRange, {
|
const allPreviews = useCameraPreviews(timeRange, {
|
||||||
camera: selectedCamera ?? undefined,
|
camera: selectedCamera ?? undefined,
|
||||||
fetchPreviews: !isSearchDialogOpen,
|
fetchPreviews: !isSearchDialogOpen,
|
||||||
|
refreshOnHourRollover: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ROI state
|
// ROI state
|
||||||
|
|||||||
Reference in New Issue
Block a user