From ec3fc782a8fea4e6996f969ed4782f20410d1653 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:02:48 +0200 Subject: [PATCH] Initial copy timestamp url implementation --- web/src/pages/Events.tsx | 71 +++++++++++++++++++++-- web/src/utils/recordingReviewUrl.ts | 42 ++++++++++++++ web/src/views/recording/RecordingView.tsx | 43 +++++++++++++- 3 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 web/src/utils/recordingReviewUrl.ts diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index e3f6e4fae..c065ffe14 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -21,16 +21,24 @@ import { getBeginningOfDayTimestamp, getEndOfDayTimestamp, } from "@/utils/dateUtil"; +import { + parseRecordingReviewLink, + RECORDING_REVIEW_START_PARAM, +} from "@/utils/recordingReviewUrl"; import EventView from "@/views/events/EventView"; import MotionSearchView from "@/views/motion-search/MotionSearchView"; import { RecordingView } from "@/views/recording/RecordingView"; import axios from "axios"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import useSWR from "swr"; export default function Events() { const { t } = useTranslation(["views/events"]); + const location = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { data: config } = useSWR("config", { revalidateOnFocus: false, @@ -127,6 +135,14 @@ export default function Events() { const [notificationTab, setNotificationTab] = useState("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) => { if (tab === "timeline" || tab === "events" || tab === "detail") { setNotificationTab(tab as TimelineType); @@ -142,10 +158,7 @@ export default function Events() { const startTime = resp.data.start_time - REVIEW_PADDING; const date = new Date(startTime * 1000); - setReviewFilter({ - after: getBeginningOfDayTimestamp(date), - before: getEndOfDayTimestamp(date), - }); + setReviewFilter(getReviewDayBounds(date)); setRecording( { camera: resp.data.camera, @@ -233,6 +246,56 @@ export default function Events() { [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 const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000)); diff --git a/web/src/utils/recordingReviewUrl.ts b/web/src/utils/recordingReviewUrl.ts new file mode 100644 index 000000000..8fddd0530 --- /dev/null +++ b/web/src/utils/recordingReviewUrl.ts @@ -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(); +} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 5758728dc..34efb8686 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -42,7 +42,8 @@ import { isTablet, } from "react-device-detect"; 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 useSWR from "swr"; import { TimeRange, TimelineType } from "@/types/timeline"; @@ -77,6 +78,9 @@ import { GenAISummaryDialog, 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 @@ -107,6 +111,7 @@ export function RecordingView({ const { t } = useTranslation(["views/events"]); const { data: config } = useSWR("config"); const navigate = useNavigate(); + const location = useLocation(); const contentRef = useRef(null); // recordings summary @@ -141,6 +146,7 @@ export function RecordingView({ ? startTime : timeRange.before - 60, ); + const lastAppliedStartTimeRef = useRef(startTime); const mainCameraReviewItems = useMemo( () => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [], @@ -317,6 +323,28 @@ export function RecordingView({ [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(() => { if (!scrubbing) { if (Math.abs(currentTime - playerTime) > 10) { @@ -663,6 +691,19 @@ export function RecordingView({ setMotionOnly={() => {}} /> )} + {isDesktop && ( {