Allow clicking on a recording

This commit is contained in:
mccahan 2026-01-22 14:36:16 -07:00
parent 04908301ec
commit 8c09103841
No known key found for this signature in database
GPG Key ID: 4AD93D9FB6994DFA
4 changed files with 61 additions and 4 deletions

View File

@ -114,6 +114,33 @@ export default function Events() {
false, false,
); );
// Wrapper to update URL with review ID for deep linking when opening a recording.
// This does a single atomic navigation to avoid creating multiple history entries
// which would cause issues with the back button.
const onOpenRecording = useCallback(
(recordingInfo: RecordingStartingPoint) => {
const updated = new URLSearchParams(searchParams);
if (recordingInfo.reviewId) {
updated.set("id", recordingInfo.reviewId);
}
const search = updated.toString();
// Single navigation that updates both URL params and location state
navigate(
{
pathname: location.pathname,
search: search ? `?${search}` : "",
hash: location.hash,
},
{
replace: true,
state: { ...location.state, recording: recordingInfo },
},
);
},
[searchParams, navigate, location.pathname, location.hash, location.state],
);
const [notificationTab, setNotificationTab] = const [notificationTab, setNotificationTab] =
useState<TimelineType>("timeline"); useState<TimelineType>("timeline");
@ -125,6 +152,13 @@ export default function Events() {
}); });
useSearchEffect("id", (reviewId: string) => { useSearchEffect("id", (reviewId: string) => {
// If recording is already set (e.g., from clicking a review), don't re-fetch.
// Return false to keep the id param in the URL for deep linking.
if (recording) {
return false;
}
// Fresh deep link - fetch review data and open recording
axios axios
.get(`review/${reviewId}`) .get(`review/${reviewId}`)
.then((resp) => { .then((resp) => {
@ -142,6 +176,7 @@ export default function Events() {
startTime, startTime,
severity: resp.data.severity, severity: resp.data.severity,
timelineType: notificationTab, timelineType: notificationTab,
reviewId,
}, },
true, true,
); );
@ -149,7 +184,8 @@ export default function Events() {
}) })
.catch(() => {}); .catch(() => {});
return true; // Return false to keep the id param in the URL for deep linking
return false;
}); });
const [startTime, setStartTime] = useState<number>(); const [startTime, setStartTime] = useState<number>();
@ -560,7 +596,7 @@ export default function Events() {
setSeverity={setSeverity} setSeverity={setSeverity}
markItemAsReviewed={markItemAsReviewed} markItemAsReviewed={markItemAsReviewed}
markAllItemsAsReviewed={markAllItemsAsReviewed} markAllItemsAsReviewed={markAllItemsAsReviewed}
onOpenRecording={setRecording} onOpenRecording={onOpenRecording}
pullLatestData={reloadData} pullLatestData={reloadData}
updateFilter={onUpdateFilter} updateFilter={onUpdateFilter}
/> />

View File

@ -39,6 +39,8 @@ export type RecordingStartingPoint = {
startTime: number; startTime: number;
severity: ReviewSeverity; severity: ReviewSeverity;
timelineType?: TimelineType; timelineType?: TimelineType;
// Optional review ID for deep linking to specific review segments
reviewId?: string;
}; };
export type RecordingPlayerError = "stalled" | "startup"; export type RecordingPlayerError = "stalled" | "startup";

View File

@ -168,6 +168,7 @@ export default function EventView({
startTime: effectiveStartTime - REVIEW_PADDING, startTime: effectiveStartTime - REVIEW_PADDING,
severity: review.severity, severity: review.severity,
timelineType: detail ? "detail" : undefined, timelineType: detail ? "detail" : undefined,
reviewId: review.id,
}); });
review.has_been_reviewed = true; review.has_been_reviewed = true;

View File

@ -35,7 +35,7 @@ import {
} from "react"; } from "react";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { useNavigate } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useSWR from "swr"; import useSWR from "swr";
import { TimeRange, TimelineType } from "@/types/timeline"; import { TimeRange, TimelineType } from "@/types/timeline";
@ -97,8 +97,26 @@ export function RecordingView({
const { t } = useTranslation(["views/events"]); const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
// Navigate back while clearing the review id param to prevent
// useSearchEffect from re-opening the recording
const handleBack = useCallback(() => {
const updated = new URLSearchParams(searchParams);
updated.delete("id");
const search = updated.toString();
navigate(
{
pathname: location.pathname,
search: search ? `?${search}` : "",
hash: location.hash,
},
{ replace: true },
);
}, [searchParams, navigate, location.pathname, location.hash]);
// recordings summary // recordings summary
const timezone = useTimezone(config); const timezone = useTimezone(config);
@ -541,7 +559,7 @@ export function RecordingView({
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })} aria-label={t("label.back", { ns: "common" })}
size="sm" size="sm"
onClick={() => navigate(-1)} onClick={handleBack}
> >
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" /> <IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && ( {isDesktop && (