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 01/30] 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 && ( { From 05d29ebc93f78d5727a084522f5031a4c6e513e5 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:33:30 +0200 Subject: [PATCH 02/30] revise url format --- web/src/pages/Events.tsx | 4 ++- web/src/utils/recordingReviewUrl.ts | 42 ++++++++++++++++++----- web/src/views/recording/RecordingView.tsx | 14 +++++--- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index c065ffe14..ee6638ba2 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -24,6 +24,7 @@ import { import { parseRecordingReviewLink, RECORDING_REVIEW_START_PARAM, + RECORDING_REVIEW_TIMEZONE_PARAM, } from "@/utils/recordingReviewUrl"; import EventView from "@/views/events/EventView"; import MotionSearchView from "@/views/motion-search/MotionSearchView"; @@ -248,6 +249,7 @@ export default function Events() { useEffect(() => { const timestamp = searchParams.get(RECORDING_REVIEW_START_PARAM); + const timezone = searchParams.get(RECORDING_REVIEW_TIMEZONE_PARAM); if (!timestamp) { return; @@ -257,7 +259,7 @@ export default function Events() { ? decodeURIComponent(location.hash.substring(1)) : null; - const reviewLink = parseRecordingReviewLink(camera, timestamp); + const reviewLink = parseRecordingReviewLink(camera, timestamp, timezone); if (!reviewLink) { navigate(location.pathname + location.hash, { diff --git a/web/src/utils/recordingReviewUrl.ts b/web/src/utils/recordingReviewUrl.ts index 8fddd0530..e409c7db9 100644 --- a/web/src/utils/recordingReviewUrl.ts +++ b/web/src/utils/recordingReviewUrl.ts @@ -1,19 +1,39 @@ +import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; + export const RECORDING_REVIEW_START_PARAM = "start_time"; +export const RECORDING_REVIEW_TIMEZONE_PARAM = "timezone"; export type RecordingReviewLinkState = { camera: string; timestamp: number; }; +function formatRecordingReviewTimestamp( + timestamp: number, + timezone: string | undefined, +): string { + const date = new Date(Math.floor(timestamp) * 1000); + + if (timezone) { + return formatInTimeZone(date, timezone, "yyyy-MM-dd'T'HH:mm:ss"); + } + + return formatInTimeZone(date, "UTC", "yyyy-MM-dd'T'HH:mm:ss'Z'"); +} + export function parseRecordingReviewLink( camera: string | null, start: string | null, + timezone: string | null, ): RecordingReviewLinkState | undefined { if (!camera || !start) { return undefined; } - const parsedTimestamp = Number(start); + const parsedDate = timezone + ? fromZonedTime(start, timezone) + : new Date(start); + const parsedTimestamp = parsedDate.getTime() / 1000; if (!Number.isFinite(parsedTimestamp)) { return undefined; @@ -28,15 +48,19 @@ export function parseRecordingReviewLink( export function createRecordingReviewUrl( pathname: string, state: RecordingReviewLinkState, + timezone?: string, ): 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)}`, + const url = new URL(globalThis.location.href); + const formattedTimestamp = formatRecordingReviewTimestamp( + state.timestamp, + timezone, ); + const normalizedPathname = pathname.startsWith("/") + ? pathname + : `/${pathname}`; + const timezoneParam = timezone + ? `&${RECORDING_REVIEW_TIMEZONE_PARAM}=${encodeURIComponent(timezone)}` + : ""; - return url.toString(); + return `${url.origin}${normalizedPathname}?${RECORDING_REVIEW_START_PARAM}=${formattedTimestamp}${timezoneParam}#${encodeURIComponent(state.camera)}`; } diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 34efb8686..620f89cd5 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -334,16 +334,20 @@ export function RecordingView({ }, [startTime, manuallySetCurrentTime]); const onCopyReviewLink = useCallback(() => { - const reviewUrl = createRecordingReviewUrl(location.pathname, { - camera: mainCamera, - timestamp: Math.floor(currentTime), - }); + const reviewUrl = createRecordingReviewUrl( + location.pathname, + { + camera: mainCamera, + timestamp: Math.floor(currentTime), + }, + config?.ui.timezone, + ); copy(reviewUrl); toast.success(t("toast.copyUrlToClipboard", { ns: "common" }), { position: "top-center", }); - }, [location.pathname, mainCamera, currentTime, t]); + }, [location.pathname, mainCamera, currentTime, config?.ui.timezone, t]); useEffect(() => { if (!scrubbing) { From 9524f8c4e53862b878deac52bae7bbdc8856ba01 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:34:13 +0200 Subject: [PATCH 03/30] Implement share timestamp dialog --- .../components/overlay/ActionsDropdown.tsx | 5 + .../overlay/MobileReviewSettingsDrawer.tsx | 22 +- .../overlay/ShareTimestampDialog.tsx | 326 ++++++++++++++++++ web/src/views/recording/RecordingView.tsx | 59 ++-- 4 files changed, 380 insertions(+), 32 deletions(-) create mode 100644 web/src/components/overlay/ShareTimestampDialog.tsx diff --git a/web/src/components/overlay/ActionsDropdown.tsx b/web/src/components/overlay/ActionsDropdown.tsx index 9ddb0bd35..e777e1a3d 100644 --- a/web/src/components/overlay/ActionsDropdown.tsx +++ b/web/src/components/overlay/ActionsDropdown.tsx @@ -11,11 +11,13 @@ import { FaFilm } from "react-icons/fa6"; type ActionsDropdownProps = { onDebugReplayClick: () => void; onExportClick: () => void; + onShareTimestampClick: () => void; }; export default function ActionsDropdown({ onDebugReplayClick, onExportClick, + onShareTimestampClick, }: ActionsDropdownProps) { const { t } = useTranslation(["components/dialog", "views/replay", "common"]); @@ -37,6 +39,9 @@ export default function ActionsDropdown({ {t("menu.export", { ns: "common" })} + + Share Timestamp + {t("title", { ns: "views/replay" })} diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 77cb8e3f4..9a4c07622 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Button } from "../ui/button"; import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; -import { LuBug } from "react-icons/lu"; +import { LuBug, LuShare2 } from "react-icons/lu"; import { TimeRange } from "@/types/timeline"; import { ExportContent, ExportPreviewDialog } from "./ExportDialog"; import { @@ -33,13 +33,15 @@ type DrawerMode = | "export" | "calendar" | "filter" - | "debug-replay"; + | "debug-replay" + | "share-timestamp"; const DRAWER_FEATURES = [ "export", "calendar", "filter", "debug-replay", + "share-timestamp", ] as const; export type DrawerFeatures = (typeof DRAWER_FEATURES)[number]; const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [ @@ -47,6 +49,7 @@ const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [ "calendar", "filter", "debug-replay", + "share-timestamp", ]; type MobileReviewSettingsDrawerProps = { @@ -67,6 +70,7 @@ type MobileReviewSettingsDrawerProps = { debugReplayRange?: TimeRange; setDebugReplayMode?: (mode: ExportMode) => void; setDebugReplayRange?: (range: TimeRange | undefined) => void; + onShareTimestampClick?: () => void; onUpdateFilter: (filter: ReviewFilter) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; @@ -90,6 +94,7 @@ export default function MobileReviewSettingsDrawer({ debugReplayRange, setDebugReplayMode = () => {}, setDebugReplayRange = () => {}, + onShareTimestampClick = () => {}, onUpdateFilter, setRange, setMode, @@ -275,6 +280,19 @@ export default function MobileReviewSettingsDrawer({ {t("export")} )} + {features.includes("share-timestamp") && ( + + )} {features.includes("calendar") && ( + + + ); +} + +type CustomTimestampSelectorProps = { + timestamp: number; + setTimestamp: (timestamp: number) => void; + label: string; +}; + +function CustomTimestampSelector({ + timestamp, + setTimestamp, + label, +}: CustomTimestampSelectorProps) { + const { t } = useTranslation(["common"]); + const { data: config } = useSWR("config"); + + const timezoneOffset = useMemo( + () => + config?.ui.timezone + ? Math.round(getUTCOffset(new Date(), config.ui.timezone)) + : undefined, + [config?.ui.timezone], + ); + const localTimeOffset = useMemo( + () => + Math.round( + getUTCOffset( + new Date(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + ), + ), + [], + ); + const offsetDeltaSeconds = useMemo(() => { + if (timezoneOffset === undefined) { + return 0; + } + + return (timezoneOffset - localTimeOffset) * 60; + }, [timezoneOffset, localTimeOffset]); + + const displayTimestamp = useMemo( + () => timestamp + offsetDeltaSeconds, + [timestamp, offsetDeltaSeconds], + ); + + const formattedTimestamp = useFormattedTimestamp( + displayTimestamp, + config?.ui.time_format == "24hour" + ? t("time.formattedTimestamp.24hour") + : t("time.formattedTimestamp.12hour"), + ); + + const clock = useMemo(() => { + const date = new Date(displayTimestamp * 1000); + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; + }, [displayTimestamp]); + + const [selectorOpen, setSelectorOpen] = useState(false); + + const setFromDisplayDate = useCallback( + (date: Date) => { + setTimestamp(date.getTime() / 1000 - offsetDeltaSeconds); + }, + [offsetDeltaSeconds, setTimestamp], + ); + + return ( +
+ +
+ { + if (!open) { + setSelectorOpen(false); + } + }} + > + + + + + { + if (!day) { + return; + } + + const nextTimestamp = new Date(displayTimestamp * 1000); + nextTimestamp.setFullYear( + day.getFullYear(), + day.getMonth(), + day.getDate(), + ); + setFromDisplayDate(nextTimestamp); + }} + /> +
+ { + const nextClock = e.target.value; + const [hour, minute, second] = isIOS + ? [...nextClock.split(":"), "00"] + : nextClock.split(":"); + const nextTimestamp = new Date(displayTimestamp * 1000); + nextTimestamp.setHours( + parseInt(hour), + parseInt(minute), + parseInt(second ?? "0"), + 0, + ); + setFromDisplayDate(nextTimestamp); + }} + /> + + +
+
+ ); +} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 620f89cd5..26ab2a00b 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -42,7 +42,6 @@ import { isTablet, } from "react-device-detect"; import { IoMdArrowRoundBack } from "react-icons/io"; -import { LuCopy } from "react-icons/lu"; import { useLocation, useNavigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; import useSWR from "swr"; @@ -78,8 +77,8 @@ import { GenAISummaryDialog, GenAISummaryChip, } from "@/components/overlay/chip/GenAISummaryChip"; -import copy from "copy-to-clipboard"; -import { toast } from "sonner"; +import ShareTimestampDialog from "@/components/overlay/ShareTimestampDialog"; +import { shareOrCopy } from "@/utils/browserUtil"; import { createRecordingReviewUrl } from "@/utils/recordingReviewUrl"; const DATA_REFRESH_TIME = 600000; // 10 minutes @@ -211,6 +210,7 @@ export function RecordingView({ const [debugReplayMode, setDebugReplayMode] = useState("none"); const [debugReplayRange, setDebugReplayRange] = useState(); + const [shareTimestampOpen, setShareTimestampOpen] = useState(false); // move to next clip @@ -333,21 +333,21 @@ export function RecordingView({ manuallySetCurrentTime(startTime); }, [startTime, manuallySetCurrentTime]); - const onCopyReviewLink = useCallback(() => { - const reviewUrl = createRecordingReviewUrl( - location.pathname, - { - camera: mainCamera, - timestamp: Math.floor(currentTime), - }, - config?.ui.timezone, - ); + const onShareReviewLink = useCallback( + (timestamp: number) => { + const reviewUrl = createRecordingReviewUrl( + location.pathname, + { + camera: mainCamera, + timestamp: Math.floor(timestamp), + }, + config?.ui.timezone, + ); - copy(reviewUrl); - toast.success(t("toast.copyUrlToClipboard", { ns: "common" }), { - position: "top-center", - }); - }, [location.pathname, mainCamera, currentTime, config?.ui.timezone, t]); + shareOrCopy(reviewUrl, `Frigate Review Timestamp: ${mainCamera}`); + }, + [location.pathname, mainCamera, config?.ui.timezone], + ); useEffect(() => { if (!scrubbing) { @@ -695,21 +695,17 @@ export function RecordingView({ setMotionOnly={() => {}} /> )} - + {isDesktop && ( { + setShareTimestampOpen(true); + }} onDebugReplayClick={() => { const now = new Date(timeRange.before * 1000); now.setHours(now.getHours() - 1); @@ -789,6 +785,9 @@ export function RecordingView({ mainControllerRef.current?.pause(); } }} + onShareTimestampClick={() => { + setShareTimestampOpen(true); + }} onUpdateFilter={updateFilter} setRange={setExportRange} setMode={setExportMode} From 5639fdfd6a3c24a76d1d2bdda8958417e28b7049 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:30:13 +0200 Subject: [PATCH 04/30] Use translations --- web/public/locales/en/common.json | 2 + web/public/locales/en/components/dialog.json | 8 ++++ .../components/overlay/ActionsDropdown.tsx | 4 +- .../overlay/MobileReviewSettingsDrawer.tsx | 5 ++- .../overlay/ShareTimestampDialog.tsx | 40 +++++++++++++------ web/src/views/recording/RecordingView.tsx | 12 ++++-- 6 files changed, 51 insertions(+), 20 deletions(-) diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 37566117a..907ff6d36 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -131,6 +131,8 @@ "close": "Close", "copy": "Copy", "copiedToClipboard": "Copied to clipboard", + "shareTimestamp": "Share Timestamp", + "shareTimestampUrl": "Share Timestamp URL", "back": "Back", "history": "History", "fullscreen": "Fullscreen", diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 9a6f68daf..b01745e43 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -99,6 +99,14 @@ } }, "recording": { + "shareTimestamp": { + "title": "Share Review Timestamp", + "description": "Share the current player position or choose a custom timestamp.", + "current": "Current Player Timestamp", + "custom": "Custom Timestamp", + "customDescription": "Pick a specific point in time to share.", + "shareTitle": "Frigate Review Timestamp: {{camera}}" + }, "confirmDelete": { "title": "Confirm Delete", "desc": { diff --git a/web/src/components/overlay/ActionsDropdown.tsx b/web/src/components/overlay/ActionsDropdown.tsx index e777e1a3d..0b71038b1 100644 --- a/web/src/components/overlay/ActionsDropdown.tsx +++ b/web/src/components/overlay/ActionsDropdown.tsx @@ -18,7 +18,7 @@ export default function ActionsDropdown({ onDebugReplayClick, onExportClick, onShareTimestampClick, -}: ActionsDropdownProps) { +}: Readonly) { const { t } = useTranslation(["components/dialog", "views/replay", "common"]); return ( @@ -40,7 +40,7 @@ export default function ActionsDropdown({ {t("menu.export", { ns: "common" })} - Share Timestamp + {t("button.shareTimestamp", { ns: "common" })} {t("title", { ns: "views/replay" })} diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 9a4c07622..24b09e59f 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -104,6 +104,7 @@ export default function MobileReviewSettingsDrawer({ "views/recording", "components/dialog", "views/replay", + "common", ]); const navigate = useNavigate(); const [drawerMode, setDrawerMode] = useState("none"); @@ -283,14 +284,14 @@ export default function MobileReviewSettingsDrawer({ {features.includes("share-timestamp") && ( )} {features.includes("calendar") && ( diff --git a/web/src/components/overlay/ShareTimestampDialog.tsx b/web/src/components/overlay/ShareTimestampDialog.tsx index 319e93513..003796489 100644 --- a/web/src/components/overlay/ShareTimestampDialog.tsx +++ b/web/src/components/overlay/ShareTimestampDialog.tsx @@ -111,8 +111,8 @@ function ShareTimestampContent({ customTimestamp, setCustomTimestamp, onShareTimestamp, -}: ShareTimestampContentProps) { - const { t } = useTranslation(["common"]); +}: Readonly) { + const { t } = useTranslation(["common", "components/dialog"]); const { data: config } = useSWR("config"); const currentTimestampLabel = useFormattedTimestamp( currentTime, @@ -127,9 +127,13 @@ function ShareTimestampContent({ return (
-
Share Review Timestamp
+
+ {t("recording.shareTimestamp.title", { ns: "components/dialog" })} +
- Share the current player position or choose a custom timestamp. + {t("recording.shareTimestamp.description", { + ns: "components/dialog", + })}
@@ -145,7 +149,9 @@ function ShareTimestampContent({
@@ -198,7 +212,7 @@ function CustomTimestampSelector({ timestamp, setTimestamp, label, -}: CustomTimestampSelectorProps) { +}: Readonly) { const { t } = useTranslation(["common"]); const { data: config } = useSWR("config"); @@ -310,9 +324,9 @@ function CustomTimestampSelector({ : nextClock.split(":"); const nextTimestamp = new Date(displayTimestamp * 1000); nextTimestamp.setHours( - parseInt(hour), - parseInt(minute), - parseInt(second ?? "0"), + Number.parseInt(hour), + Number.parseInt(minute), + Number.parseInt(second ?? "0"), 0, ); setFromDisplayDate(nextTimestamp); diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 26ab2a00b..86f664d9a 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -107,7 +107,7 @@ export function RecordingView({ updateFilter, refreshData, }: RecordingViewProps) { - const { t } = useTranslation(["views/events"]); + const { t } = useTranslation(["views/events", "components/dialog"]); const { data: config } = useSWR("config"); const navigate = useNavigate(); const location = useLocation(); @@ -344,9 +344,15 @@ export function RecordingView({ config?.ui.timezone, ); - shareOrCopy(reviewUrl, `Frigate Review Timestamp: ${mainCamera}`); + shareOrCopy( + reviewUrl, + t("recording.shareTimestamp.shareTitle", { + ns: "components/dialog", + camera: mainCamera, + }), + ); }, - [location.pathname, mainCamera, config?.ui.timezone], + [location.pathname, mainCamera, config?.ui.timezone, t], ); useEffect(() => { From b4a632e818f69d7a3b8766a775e92fc090a0cdef Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:37:15 +0200 Subject: [PATCH 05/30] Add comments --- web/src/components/overlay/ShareTimestampDialog.tsx | 3 +++ web/src/pages/Events.tsx | 13 ++++++++++--- web/src/utils/recordingReviewUrl.ts | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/web/src/components/overlay/ShareTimestampDialog.tsx b/web/src/components/overlay/ShareTimestampDialog.tsx index 003796489..c92a3aed3 100644 --- a/web/src/components/overlay/ShareTimestampDialog.tsx +++ b/web/src/components/overlay/ShareTimestampDialog.tsx @@ -238,6 +238,8 @@ function CustomTimestampSelector({ return 0; } + // the picker edits a timestamp in the configured UI timezone, + // but the stored value remains a unix timestamp return (timezoneOffset - localTimeOffset) * 60; }, [timezoneOffset, localTimeOffset]); @@ -262,6 +264,7 @@ function CustomTimestampSelector({ const setFromDisplayDate = useCallback( (date: Date) => { + // convert the edited display time back into the underlying Unix timestamp setTimestamp(date.getTime() / 1000 - offsetDeltaSeconds); }, [offsetDeltaSeconds, setTimestamp], diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index ee6638ba2..b1e829037 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -247,6 +247,11 @@ export default function Events() { [recording, setRecording, setReviewFilter], ); + // shared recording links enter /review through query params, but the + // existing recording view is opened via router state (`recording`) + + // this effect translates the URL entry point into the state shape the + // rest of the page already uses, then cleans the URL back to plain /review useEffect(() => { const timestamp = searchParams.get(RECORDING_REVIEW_START_PARAM); const timezone = searchParams.get(RECORDING_REVIEW_TIMEZONE_PARAM); @@ -262,16 +267,18 @@ export default function Events() { const reviewLink = parseRecordingReviewLink(camera, timestamp, timezone); if (!reviewLink) { - navigate(location.pathname + location.hash, { + navigate(location.pathname, { state: location.state, replace: true, }); return; } - const nextRecording = { + const nextRecording: RecordingStartingPoint = { camera: reviewLink.camera, startTime: reviewLink.timestamp, + // severity not actually applicable here, but the type requires it + // this pattern is also used LiveCameraView to enter recording view severity: "alert" as const, }; @@ -280,7 +287,7 @@ export default function Events() { ...getReviewDayBounds(new Date(reviewLink.timestamp * 1000)), }); - navigate(location.pathname + location.hash, { + navigate(location.pathname, { state: { ...location.state, recording: nextRecording, diff --git a/web/src/utils/recordingReviewUrl.ts b/web/src/utils/recordingReviewUrl.ts index e409c7db9..2083331bf 100644 --- a/web/src/utils/recordingReviewUrl.ts +++ b/web/src/utils/recordingReviewUrl.ts @@ -15,9 +15,12 @@ function formatRecordingReviewTimestamp( const date = new Date(Math.floor(timestamp) * 1000); if (timezone) { + // when the UI timezone is configured, keep the URL readable by storing + // local time plus a separate timezone query param return formatInTimeZone(date, timezone, "yyyy-MM-dd'T'HH:mm:ss"); } + // without a configured UI timezone, fall back to UTC timestamp return formatInTimeZone(date, "UTC", "yyyy-MM-dd'T'HH:mm:ss'Z'"); } From 878e81e05b9a8b5be86bc8bf5cd4eff12f109cd9 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:09:58 +0200 Subject: [PATCH 06/30] Add validations to shared link --- web/public/locales/en/views/events.json | 4 ++- web/src/pages/Events.tsx | 39 ++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index 2efbd2652..00c27e978 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -43,7 +43,9 @@ }, "documentTitle": "Review - Frigate", "recordings": { - "documentTitle": "Recordings - Frigate" + "documentTitle": "Recordings - Frigate", + "invalidSharedLink": "Unable to open timestamped recording link due to parsing error.", + "invalidSharedCamera": "Unable to open timestamped recording link due to an unknown or unauthorized camera." }, "calendarFilter": { "last24Hours": "Last 24 Hours" diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index b1e829037..7d4ef42cc 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -30,9 +30,10 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; import useSWR from "swr"; export default function Events() { @@ -74,6 +75,7 @@ export default function Events() { const [motionSearchDay, setMotionSearchDay] = useState( undefined, ); + const handledReviewLinkRef = useRef(null); const motionSearchCameras = useMemo(() => { if (!config?.cameras) { @@ -257,16 +259,48 @@ export default function Events() { const timezone = searchParams.get(RECORDING_REVIEW_TIMEZONE_PARAM); if (!timestamp) { + handledReviewLinkRef.current = null; + return; + } + + if (!config) { return; } const camera = location.hash ? decodeURIComponent(location.hash.substring(1)) : null; + const reviewLinkKey = `${camera ?? ""}|${timestamp}|${timezone ?? ""}`; + + if (handledReviewLinkRef.current === reviewLinkKey) { + return; + } + + handledReviewLinkRef.current = reviewLinkKey; const reviewLink = parseRecordingReviewLink(camera, timestamp, timezone); if (!reviewLink) { + toast.error(t("recordings.invalidSharedLink"), { + position: "top-center", + }); + navigate(location.pathname, { + state: location.state, + replace: true, + }); + return; + } + + // reject unknown or unauthorized cameras before switching into + // recording view so bad links cleanly fall back to plain /review + const validCamera = + config.cameras[reviewLink.camera] && + allowedCameras.includes(reviewLink.camera); + + if (!validCamera) { + toast.error(t("recordings.invalidSharedCamera"), { + position: "top-center", + }); navigate(location.pathname, { state: location.state, replace: true, @@ -298,11 +332,14 @@ export default function Events() { location.hash, location.pathname, location.state, + config, navigate, + allowedCameras, getReviewDayBounds, reviewFilter, searchParams, setReviewFilter, + t, ]); // review paging From 1c987581ffc1d3013b6e61da1cb00418df88d183 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:54:54 +0200 Subject: [PATCH 07/30] Switch to searchEffect implementation --- web/src/pages/Events.tsx | 95 ++++++----------------------- web/src/utils/recordingReviewUrl.ts | 21 ++++--- 2 files changed, 32 insertions(+), 84 deletions(-) diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 7d4ef42cc..e2c2a1db1 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -23,24 +23,19 @@ import { } from "@/utils/dateUtil"; import { parseRecordingReviewLink, - RECORDING_REVIEW_START_PARAM, - RECORDING_REVIEW_TIMEZONE_PARAM, + RECORDING_REVIEW_LINK_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, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; 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, @@ -75,7 +70,6 @@ export default function Events() { const [motionSearchDay, setMotionSearchDay] = useState( undefined, ); - const handledReviewLinkRef = useRef(null); const motionSearchCameras = useMemo(() => { if (!config?.cameras) { @@ -249,50 +243,20 @@ export default function Events() { [recording, setRecording, setReviewFilter], ); - // shared recording links enter /review through query params, but the - // existing recording view is opened via router state (`recording`) - - // this effect translates the URL entry point into the state shape the - // rest of the page already uses, then cleans the URL back to plain /review - useEffect(() => { - const timestamp = searchParams.get(RECORDING_REVIEW_START_PARAM); - const timezone = searchParams.get(RECORDING_REVIEW_TIMEZONE_PARAM); - - if (!timestamp) { - handledReviewLinkRef.current = null; - return; - } - + useSearchEffect(RECORDING_REVIEW_LINK_PARAM, (reviewLinkValue: string) => { if (!config) { - return; + return false; } - const camera = location.hash - ? decodeURIComponent(location.hash.substring(1)) - : null; - const reviewLinkKey = `${camera ?? ""}|${timestamp}|${timezone ?? ""}`; - - if (handledReviewLinkRef.current === reviewLinkKey) { - return; - } - - handledReviewLinkRef.current = reviewLinkKey; - - const reviewLink = parseRecordingReviewLink(camera, timestamp, timezone); + const reviewLink = parseRecordingReviewLink(reviewLinkValue); if (!reviewLink) { toast.error(t("recordings.invalidSharedLink"), { position: "top-center", }); - navigate(location.pathname, { - state: location.state, - replace: true, - }); - return; + return true; } - // reject unknown or unauthorized cameras before switching into - // recording view so bad links cleanly fall back to plain /review const validCamera = config.cameras[reviewLink.camera] && allowedCameras.includes(reviewLink.camera); @@ -301,46 +265,27 @@ export default function Events() { toast.error(t("recordings.invalidSharedCamera"), { position: "top-center", }); - navigate(location.pathname, { - state: location.state, - replace: true, - }); - return; + return true; } - const nextRecording: RecordingStartingPoint = { - camera: reviewLink.camera, - startTime: reviewLink.timestamp, - // severity not actually applicable here, but the type requires it - // this pattern is also used LiveCameraView to enter recording view - severity: "alert" as const, - }; - setReviewFilter({ ...reviewFilter, ...getReviewDayBounds(new Date(reviewLink.timestamp * 1000)), }); - - navigate(location.pathname, { - state: { - ...location.state, - recording: nextRecording, + setRecording( + { + camera: reviewLink.camera, + startTime: reviewLink.timestamp, + // severity not actually applicable here, but the type requires it + // this pattern is also used LiveCameraView to enter recording view + severity: "alert", + timelineType: notificationTab, }, - replace: true, - }); - }, [ - location.hash, - location.pathname, - location.state, - config, - navigate, - allowedCameras, - getReviewDayBounds, - reviewFilter, - searchParams, - setReviewFilter, - t, - ]); + true, + ); + + return false; + }); // review paging diff --git a/web/src/utils/recordingReviewUrl.ts b/web/src/utils/recordingReviewUrl.ts index 2083331bf..4cfc79014 100644 --- a/web/src/utils/recordingReviewUrl.ts +++ b/web/src/utils/recordingReviewUrl.ts @@ -1,7 +1,6 @@ import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; -export const RECORDING_REVIEW_START_PARAM = "start_time"; -export const RECORDING_REVIEW_TIMEZONE_PARAM = "timezone"; +export const RECORDING_REVIEW_LINK_PARAM = "timestamp"; export type RecordingReviewLinkState = { camera: string; @@ -25,10 +24,14 @@ function formatRecordingReviewTimestamp( } export function parseRecordingReviewLink( - camera: string | null, - start: string | null, - timezone: string | null, + value: string | null, ): RecordingReviewLinkState | undefined { + if (!value) { + return undefined; + } + + const [camera, start, timezone] = value.split("|"); + if (!camera || !start) { return undefined; } @@ -61,9 +64,9 @@ export function createRecordingReviewUrl( const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`; - const timezoneParam = timezone - ? `&${RECORDING_REVIEW_TIMEZONE_PARAM}=${encodeURIComponent(timezone)}` - : ""; + const reviewLink = timezone + ? `${state.camera}|${formattedTimestamp}|${timezone}` + : `${state.camera}|${formattedTimestamp}`; - return `${url.origin}${normalizedPathname}?${RECORDING_REVIEW_START_PARAM}=${formattedTimestamp}${timezoneParam}#${encodeURIComponent(state.camera)}`; + return `${url.origin}${normalizedPathname}?${RECORDING_REVIEW_LINK_PARAM}=${reviewLink}`; } From d8f7ca27ed8664d364d217b795c0328ed2176770 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:59:44 +0200 Subject: [PATCH 08/30] Add missing accessibility related dialog description --- web/src/components/overlay/ShareTimestampDialog.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/src/components/overlay/ShareTimestampDialog.tsx b/web/src/components/overlay/ShareTimestampDialog.tsx index c92a3aed3..bcbb2ee15 100644 --- a/web/src/components/overlay/ShareTimestampDialog.tsx +++ b/web/src/components/overlay/ShareTimestampDialog.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -88,6 +89,11 @@ export default function ShareTimestampDialog({ {t("recording.shareTimestamp.title", { ns: "components/dialog" })} + + {t("recording.shareTimestamp.description", { + ns: "components/dialog", + })} + {content} From 64dcf77361ee0bc9fb4a5d587493b83384607bdc Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:19:33 +0200 Subject: [PATCH 09/30] Change URL format to unix timestamps --- web/src/utils/recordingReviewUrl.ts | 36 +++-------------------- web/src/views/recording/RecordingView.tsx | 14 ++++----- 2 files changed, 9 insertions(+), 41 deletions(-) diff --git a/web/src/utils/recordingReviewUrl.ts b/web/src/utils/recordingReviewUrl.ts index 4cfc79014..f025814f1 100644 --- a/web/src/utils/recordingReviewUrl.ts +++ b/web/src/utils/recordingReviewUrl.ts @@ -1,5 +1,3 @@ -import { formatInTimeZone, fromZonedTime } from "date-fns-tz"; - export const RECORDING_REVIEW_LINK_PARAM = "timestamp"; export type RecordingReviewLinkState = { @@ -7,22 +5,6 @@ export type RecordingReviewLinkState = { timestamp: number; }; -function formatRecordingReviewTimestamp( - timestamp: number, - timezone: string | undefined, -): string { - const date = new Date(Math.floor(timestamp) * 1000); - - if (timezone) { - // when the UI timezone is configured, keep the URL readable by storing - // local time plus a separate timezone query param - return formatInTimeZone(date, timezone, "yyyy-MM-dd'T'HH:mm:ss"); - } - - // without a configured UI timezone, fall back to UTC timestamp - return formatInTimeZone(date, "UTC", "yyyy-MM-dd'T'HH:mm:ss'Z'"); -} - export function parseRecordingReviewLink( value: string | null, ): RecordingReviewLinkState | undefined { @@ -30,16 +12,13 @@ export function parseRecordingReviewLink( return undefined; } - const [camera, start, timezone] = value.split("|"); + const [camera, timestamp] = value.split("|"); - if (!camera || !start) { + if (!camera || !timestamp) { return undefined; } - const parsedDate = timezone - ? fromZonedTime(start, timezone) - : new Date(start); - const parsedTimestamp = parsedDate.getTime() / 1000; + const parsedTimestamp = Number(timestamp); if (!Number.isFinite(parsedTimestamp)) { return undefined; @@ -54,19 +33,12 @@ export function parseRecordingReviewLink( export function createRecordingReviewUrl( pathname: string, state: RecordingReviewLinkState, - timezone?: string, ): string { const url = new URL(globalThis.location.href); - const formattedTimestamp = formatRecordingReviewTimestamp( - state.timestamp, - timezone, - ); const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`; - const reviewLink = timezone - ? `${state.camera}|${formattedTimestamp}|${timezone}` - : `${state.camera}|${formattedTimestamp}`; + const reviewLink = `${state.camera}|${Math.floor(state.timestamp)}`; return `${url.origin}${normalizedPathname}?${RECORDING_REVIEW_LINK_PARAM}=${reviewLink}`; } diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 86f664d9a..74f6b9d2f 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -335,14 +335,10 @@ export function RecordingView({ const onShareReviewLink = useCallback( (timestamp: number) => { - const reviewUrl = createRecordingReviewUrl( - location.pathname, - { - camera: mainCamera, - timestamp: Math.floor(timestamp), - }, - config?.ui.timezone, - ); + const reviewUrl = createRecordingReviewUrl(location.pathname, { + camera: mainCamera, + timestamp: Math.floor(timestamp), + }); shareOrCopy( reviewUrl, @@ -352,7 +348,7 @@ export function RecordingView({ }), ); }, - [location.pathname, mainCamera, config?.ui.timezone, t], + [location.pathname, mainCamera, t], ); useEffect(() => { From 75f5177e6b1c07f39e8963185f5be4e4a516fa71 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:41:26 +0200 Subject: [PATCH 10/30] Remove unnecessary useEffect --- web/src/views/recording/RecordingView.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 74f6b9d2f..f0ab35a6c 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -145,7 +145,6 @@ export function RecordingView({ ? startTime : timeRange.before - 60, ); - const lastAppliedStartTimeRef = useRef(startTime); const mainCameraReviewItems = useMemo( () => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [], @@ -323,16 +322,6 @@ export function RecordingView({ [currentTimeRange, updateSelectedSegment], ); - useEffect(() => { - if (lastAppliedStartTimeRef.current === startTime) { - return; - } - - lastAppliedStartTimeRef.current = startTime; - setPlayerTime(startTime); - manuallySetCurrentTime(startTime); - }, [startTime, manuallySetCurrentTime]); - const onShareReviewLink = useCallback( (timestamp: number) => { const reviewUrl = createRecordingReviewUrl(location.pathname, { From 6fa64a8db3702e7dbce86917444e2ee77b46a3f5 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:55:28 +0200 Subject: [PATCH 11/30] Remove duplicated dialog title --- web/src/components/overlay/ShareTimestampDialog.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/src/components/overlay/ShareTimestampDialog.tsx b/web/src/components/overlay/ShareTimestampDialog.tsx index bcbb2ee15..77f1f7ce0 100644 --- a/web/src/components/overlay/ShareTimestampDialog.tsx +++ b/web/src/components/overlay/ShareTimestampDialog.tsx @@ -133,9 +133,6 @@ function ShareTimestampContent({ return (
-
- {t("recording.shareTimestamp.title", { ns: "components/dialog" })} -
{t("recording.shareTimestamp.description", { ns: "components/dialog", From 5dad8cfb2d569cfd52bdb48b8e995f66ed22f0d0 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:27:36 +0200 Subject: [PATCH 12/30] Fixes/improvements based off PR review comments --- web/public/locales/en/common.json | 2 - web/public/locales/en/components/dialog.json | 2 + .../components/overlay/ActionsDropdown.tsx | 2 +- .../overlay/MobileReviewSettingsDrawer.tsx | 58 +++++++++++++++++-- .../overlay/ShareTimestampDialog.tsx | 40 +++++++++---- web/src/utils/recordingReviewUrl.ts | 13 ++++- web/src/views/recording/RecordingView.tsx | 23 +++++--- 7 files changed, 108 insertions(+), 32 deletions(-) diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 907ff6d36..37566117a 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -131,8 +131,6 @@ "close": "Close", "copy": "Copy", "copiedToClipboard": "Copied to clipboard", - "shareTimestamp": "Share Timestamp", - "shareTimestampUrl": "Share Timestamp URL", "back": "Back", "history": "History", "fullscreen": "Fullscreen", diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index b01745e43..767dc7dcf 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -100,11 +100,13 @@ }, "recording": { "shareTimestamp": { + "label": "Share Timestamp", "title": "Share Review Timestamp", "description": "Share the current player position or choose a custom timestamp.", "current": "Current Player Timestamp", "custom": "Custom Timestamp", "customDescription": "Pick a specific point in time to share.", + "button": "Share Timestamp URL", "shareTitle": "Frigate Review Timestamp: {{camera}}" }, "confirmDelete": { diff --git a/web/src/components/overlay/ActionsDropdown.tsx b/web/src/components/overlay/ActionsDropdown.tsx index 0b71038b1..7f841be4f 100644 --- a/web/src/components/overlay/ActionsDropdown.tsx +++ b/web/src/components/overlay/ActionsDropdown.tsx @@ -40,7 +40,7 @@ export default function ActionsDropdown({ {t("menu.export", { ns: "common" })} - {t("button.shareTimestamp", { ns: "common" })} + {t("recording.shareTimestamp.label", { ns: "components/dialog" })} {t("title", { ns: "views/replay" })} diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 24b09e59f..6df197e47 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -26,6 +26,7 @@ import SaveExportOverlay from "./SaveExportOverlay"; import { isIOS, isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; +import { ShareTimestampContent } from "./ShareTimestampDialog"; type DrawerMode = | "none" @@ -70,7 +71,7 @@ type MobileReviewSettingsDrawerProps = { debugReplayRange?: TimeRange; setDebugReplayMode?: (mode: ExportMode) => void; setDebugReplayRange?: (range: TimeRange | undefined) => void; - onShareTimestampClick?: () => void; + onShareTimestamp?: (timestamp: number) => void; onUpdateFilter: (filter: ReviewFilter) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; @@ -94,7 +95,7 @@ export default function MobileReviewSettingsDrawer({ debugReplayRange, setDebugReplayMode = () => {}, setDebugReplayRange = () => {}, - onShareTimestampClick = () => {}, + onShareTimestamp = () => {}, onUpdateFilter, setRange, setMode, @@ -112,6 +113,15 @@ export default function MobileReviewSettingsDrawer({ "1" | "5" | "custom" | "timeline" >("1"); const [isDebugReplayStarting, setIsDebugReplayStarting] = useState(false); + const [selectedShareOption, setSelectedShareOption] = useState< + "current" | "custom" + >("current"); + const [shareTimestampAtOpen, setShareTimestampAtOpen] = useState( + Math.floor(currentTime), + ); + const [customShareTimestamp, setCustomShareTimestamp] = useState( + Math.floor(currentTime), + ); // exports @@ -284,14 +294,22 @@ export default function MobileReviewSettingsDrawer({ {features.includes("share-timestamp") && ( )} {features.includes("calendar") && ( @@ -496,6 +514,34 @@ export default function MobileReviewSettingsDrawer({ }} /> ); + } else if (drawerMode == "share-timestamp") { + content = ( +
+
+
setDrawerMode("select")} + > + {t("button.back", { ns: "common" })} +
+
+ {t("recording.shareTimestamp.title", { ns: "components/dialog" })} +
+
+ { + onShareTimestamp(timestamp); + setDrawerMode("none"); + }} + onCancel={() => setDrawerMode("select")} + /> +
+ ); } return ( diff --git a/web/src/components/overlay/ShareTimestampDialog.tsx b/web/src/components/overlay/ShareTimestampDialog.tsx index 77f1f7ce0..80d713a95 100644 --- a/web/src/components/overlay/ShareTimestampDialog.tsx +++ b/web/src/components/overlay/ShareTimestampDialog.tsx @@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -22,7 +23,6 @@ import useSWR from "swr"; import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { FaCalendarAlt } from "react-icons/fa"; import { isDesktop, isIOS, isMobile } from "react-device-detect"; -import { LuShare2 } from "react-icons/lu"; import { useTranslation } from "react-i18next"; type ShareTimestampDialogProps = { @@ -42,15 +42,19 @@ export default function ShareTimestampDialog({ const [selectedOption, setSelectedOption] = useState<"current" | "custom">( "current", ); - const [customTimestamp, setCustomTimestamp] = useState( + const [openedCurrentTime, setOpenedCurrentTime] = useState( Math.floor(currentTime), ); + const [customTimestamp, setCustomTimestamp] = useState(openedCurrentTime); const handleOpenChange = useCallback( (nextOpen: boolean) => { if (nextOpen) { + const initialTimestamp = Math.floor(currentTime); + + setOpenedCurrentTime(initialTimestamp); setSelectedOption("current"); - setCustomTimestamp(Math.floor(currentTime)); + setCustomTimestamp(initialTimestamp); } onOpenChange(nextOpen); @@ -60,7 +64,7 @@ export default function ShareTimestampDialog({ const content = ( void; onShareTimestamp: (timestamp: number) => void; + onCancel?: () => void; }; -function ShareTimestampContent({ +export function ShareTimestampContent({ currentTime, selectedOption, setSelectedOption, customTimestamp, setCustomTimestamp, onShareTimestamp, + onCancel, }: Readonly) { const { t } = useTranslation(["common", "components/dialog"]); const { data: config } = useSWR("config"); @@ -191,16 +197,28 @@ function ShareTimestampContent({
-
+ + {onCancel && ( + + )} -
+
); } @@ -298,7 +316,7 @@ function CustomTimestampSelector({ {formattedTimestamp} - + ("none"); const [debugReplayRange, setDebugReplayRange] = useState(); const [shareTimestampOpen, setShareTimestampOpen] = useState(false); + const [shareTimestampAtOpen, setShareTimestampAtOpen] = useState( + Math.floor(startTime), + ); // move to next clip @@ -686,15 +689,19 @@ export function RecordingView({ setMotionOnly={() => {}} /> )} - + {isDesktop && ( + + )} {isDesktop && ( { + setShareTimestampAtOpen(Math.floor(currentTime)); setShareTimestampOpen(true); }} onDebugReplayClick={() => { @@ -776,9 +783,7 @@ export function RecordingView({ mainControllerRef.current?.pause(); } }} - onShareTimestampClick={() => { - setShareTimestampOpen(true); - }} + onShareTimestamp={onShareReviewLink} onUpdateFilter={updateFilter} setRange={setExportRange} setMode={setExportMode} From a0956ebe0f5c813c7ef66a671f4b101e836ab569 Mon Sep 17 00:00:00 2001 From: 0x464e <36742501+0x464e@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:44:49 +0200 Subject: [PATCH 13/30] Add missing cancel button & separators to dialog --- .../components/overlay/ShareTimestampDialog.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/web/src/components/overlay/ShareTimestampDialog.tsx b/web/src/components/overlay/ShareTimestampDialog.tsx index 80d713a95..4c13811e1 100644 --- a/web/src/components/overlay/ShareTimestampDialog.tsx +++ b/web/src/components/overlay/ShareTimestampDialog.tsx @@ -15,6 +15,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { SelectSeparator } from "@/components/ui/select"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { FrigateConfig } from "@/types/frigateConfig"; import { getUTCOffset } from "@/utils/dateUtil"; @@ -73,6 +74,7 @@ export default function ShareTimestampDialog({ onShareTimestamp(timestamp); onOpenChange(false); }} + onCancel={() => onOpenChange(false)} /> ); @@ -146,6 +148,8 @@ export function ShareTimestampContent({
+ {isDesktop && } + + {isDesktop && } + {onCancel && ( - + )} + )} + )}