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] 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}