Initial copy timestamp url implementation

This commit is contained in:
0x464e 2026-03-19 00:02:48 +02:00
parent 722ef6a1fe
commit ec3fc782a8
No known key found for this signature in database
GPG Key ID: E6D221DF6CBFBFFA
3 changed files with 151 additions and 5 deletions

View File

@ -21,16 +21,24 @@ import {
getBeginningOfDayTimestamp, getBeginningOfDayTimestamp,
getEndOfDayTimestamp, getEndOfDayTimestamp,
} from "@/utils/dateUtil"; } from "@/utils/dateUtil";
import {
parseRecordingReviewLink,
RECORDING_REVIEW_START_PARAM,
} from "@/utils/recordingReviewUrl";
import EventView from "@/views/events/EventView"; import EventView from "@/views/events/EventView";
import MotionSearchView from "@/views/motion-search/MotionSearchView"; import MotionSearchView from "@/views/motion-search/MotionSearchView";
import { RecordingView } from "@/views/recording/RecordingView"; import { RecordingView } from "@/views/recording/RecordingView";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
export default function Events() { export default function Events() {
const { t } = useTranslation(["views/events"]); const { t } = useTranslation(["views/events"]);
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
@ -127,6 +135,14 @@ export default function Events() {
const [notificationTab, setNotificationTab] = const [notificationTab, setNotificationTab] =
useState<TimelineType>("timeline"); useState<TimelineType>("timeline");
const getReviewDayBounds = useCallback((date: Date) => {
const now = Date.now() / 1000;
return {
after: getBeginningOfDayTimestamp(date),
before: Math.min(getEndOfDayTimestamp(date), now),
};
}, []);
useSearchEffect("tab", (tab: string) => { useSearchEffect("tab", (tab: string) => {
if (tab === "timeline" || tab === "events" || tab === "detail") { if (tab === "timeline" || tab === "events" || tab === "detail") {
setNotificationTab(tab as TimelineType); setNotificationTab(tab as TimelineType);
@ -142,10 +158,7 @@ export default function Events() {
const startTime = resp.data.start_time - REVIEW_PADDING; const startTime = resp.data.start_time - REVIEW_PADDING;
const date = new Date(startTime * 1000); const date = new Date(startTime * 1000);
setReviewFilter({ setReviewFilter(getReviewDayBounds(date));
after: getBeginningOfDayTimestamp(date),
before: getEndOfDayTimestamp(date),
});
setRecording( setRecording(
{ {
camera: resp.data.camera, camera: resp.data.camera,
@ -233,6 +246,56 @@ export default function Events() {
[recording, setRecording, setReviewFilter], [recording, setRecording, setReviewFilter],
); );
useEffect(() => {
const timestamp = searchParams.get(RECORDING_REVIEW_START_PARAM);
if (!timestamp) {
return;
}
const camera = location.hash
? decodeURIComponent(location.hash.substring(1))
: null;
const reviewLink = parseRecordingReviewLink(camera, timestamp);
if (!reviewLink) {
navigate(location.pathname + location.hash, {
state: location.state,
replace: true,
});
return;
}
const nextRecording = {
camera: reviewLink.camera,
startTime: reviewLink.timestamp,
severity: "alert" as const,
};
setReviewFilter({
...reviewFilter,
...getReviewDayBounds(new Date(reviewLink.timestamp * 1000)),
});
navigate(location.pathname + location.hash, {
state: {
...location.state,
recording: nextRecording,
},
replace: true,
});
}, [
location.hash,
location.pathname,
location.state,
navigate,
getReviewDayBounds,
reviewFilter,
searchParams,
setReviewFilter,
]);
// review paging // review paging
const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000)); const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000));

View File

@ -0,0 +1,42 @@
export const RECORDING_REVIEW_START_PARAM = "start_time";
export type RecordingReviewLinkState = {
camera: string;
timestamp: number;
};
export function parseRecordingReviewLink(
camera: string | null,
start: string | null,
): RecordingReviewLinkState | undefined {
if (!camera || !start) {
return undefined;
}
const parsedTimestamp = Number(start);
if (!Number.isFinite(parsedTimestamp)) {
return undefined;
}
return {
camera,
timestamp: Math.floor(parsedTimestamp),
};
}
export function createRecordingReviewUrl(
pathname: string,
state: RecordingReviewLinkState,
): string {
const url = new URL(window.location.href);
url.pathname = pathname;
url.hash = state.camera;
url.search = "";
url.searchParams.set(
RECORDING_REVIEW_START_PARAM,
`${Math.floor(state.timestamp)}`,
);
return url.toString();
}

View File

@ -42,7 +42,8 @@ import {
isTablet, isTablet,
} from "react-device-detect"; } from "react-device-detect";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { useNavigate } from "react-router-dom"; import { LuCopy } from "react-icons/lu";
import { useLocation, useNavigate } 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";
@ -77,6 +78,9 @@ import {
GenAISummaryDialog, GenAISummaryDialog,
GenAISummaryChip, GenAISummaryChip,
} from "@/components/overlay/chip/GenAISummaryChip"; } from "@/components/overlay/chip/GenAISummaryChip";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { createRecordingReviewUrl } from "@/utils/recordingReviewUrl";
const DATA_REFRESH_TIME = 600000; // 10 minutes const DATA_REFRESH_TIME = 600000; // 10 minutes
@ -107,6 +111,7 @@ 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 contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
// recordings summary // recordings summary
@ -141,6 +146,7 @@ export function RecordingView({
? startTime ? startTime
: timeRange.before - 60, : timeRange.before - 60,
); );
const lastAppliedStartTimeRef = useRef(startTime);
const mainCameraReviewItems = useMemo( const mainCameraReviewItems = useMemo(
() => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [], () => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [],
@ -317,6 +323,28 @@ export function RecordingView({
[currentTimeRange, updateSelectedSegment], [currentTimeRange, updateSelectedSegment],
); );
useEffect(() => {
if (lastAppliedStartTimeRef.current === startTime) {
return;
}
lastAppliedStartTimeRef.current = startTime;
setPlayerTime(startTime);
manuallySetCurrentTime(startTime);
}, [startTime, manuallySetCurrentTime]);
const onCopyReviewLink = useCallback(() => {
const reviewUrl = createRecordingReviewUrl(location.pathname, {
camera: mainCamera,
timestamp: Math.floor(currentTime),
});
copy(reviewUrl);
toast.success(t("toast.copyUrlToClipboard", { ns: "common" }), {
position: "top-center",
});
}, [location.pathname, mainCamera, currentTime, t]);
useEffect(() => { useEffect(() => {
if (!scrubbing) { if (!scrubbing) {
if (Math.abs(currentTime - playerTime) > 10) { if (Math.abs(currentTime - playerTime) > 10) {
@ -663,6 +691,19 @@ export function RecordingView({
setMotionOnly={() => {}} setMotionOnly={() => {}}
/> />
)} )}
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("button.copy", { ns: "common" })}
size="sm"
onClick={onCopyReviewLink}
>
<LuCopy className="size-4 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.copy", { ns: "common" })}
</div>
)}
</Button>
{isDesktop && ( {isDesktop && (
<ActionsDropdown <ActionsDropdown
onDebugReplayClick={() => { onDebugReplayClick={() => {