Fix preview players at the hour rollover (#24157)

* fix preview players at the hour rollover

* clean up
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 6ae8050974
commit 53b04f44ef
9 changed files with 320 additions and 22 deletions
+35 -13
View File
@@ -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(
+53
View File
@@ -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]);
}