diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 9a6f68daf..0231cfe41 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -99,6 +99,14 @@ } }, "recording": { + "shareTimestamp": { + "label": "Share Timestamp", + "title": "Share Timestamp", + "description": "Share a timestamped URL of current player position or choose a custom timestamp. Note that this is not a public share URL and is only accessible to users with access to Frigate and this camera.", + "custom": "Custom Timestamp", + "button": "Share Timestamp URL", + "shareTitle": "Frigate Review Timestamp: {{camera}}" + }, "confirmDelete": { "title": "Confirm Delete", "desc": { diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json index a829d3687..103d93b7e 100644 --- a/web/public/locales/en/views/events.json +++ b/web/public/locales/en/views/events.json @@ -45,7 +45,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/components/overlay/ActionsDropdown.tsx b/web/src/components/overlay/ActionsDropdown.tsx index 9ddb0bd35..7f841be4f 100644 --- a/web/src/components/overlay/ActionsDropdown.tsx +++ b/web/src/components/overlay/ActionsDropdown.tsx @@ -11,12 +11,14 @@ import { FaFilm } from "react-icons/fa6"; type ActionsDropdownProps = { onDebugReplayClick: () => void; onExportClick: () => void; + onShareTimestampClick: () => void; }; export default function ActionsDropdown({ onDebugReplayClick, onExportClick, -}: ActionsDropdownProps) { + onShareTimestampClick, +}: Readonly) { const { t } = useTranslation(["components/dialog", "views/replay", "common"]); return ( @@ -37,6 +39,9 @@ export default function ActionsDropdown({ {t("menu.export", { 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 4a54bb142..72bcbbfc1 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 { @@ -26,6 +26,7 @@ import SaveExportOverlay from "./SaveExportOverlay"; import { isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; +import { ShareTimestampContent } from "./ShareTimestampDialog"; type DrawerMode = | "none" @@ -33,13 +34,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 +50,7 @@ const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [ "calendar", "filter", "debug-replay", + "share-timestamp", ]; type MobileReviewSettingsDrawerProps = { @@ -67,6 +71,7 @@ type MobileReviewSettingsDrawerProps = { debugReplayRange?: TimeRange; setDebugReplayMode?: (mode: ExportMode) => void; setDebugReplayRange?: (range: TimeRange | undefined) => void; + onShareTimestamp?: (timestamp: number) => void; onUpdateFilter: (filter: ReviewFilter) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; @@ -90,6 +95,7 @@ export default function MobileReviewSettingsDrawer({ debugReplayRange, setDebugReplayMode = () => {}, setDebugReplayRange = () => {}, + onShareTimestamp = () => {}, onUpdateFilter, setRange, setMode, @@ -99,6 +105,7 @@ export default function MobileReviewSettingsDrawer({ "views/recording", "components/dialog", "views/replay", + "common", ]); const navigate = useNavigate(); const [drawerMode, setDrawerMode] = useState("none"); @@ -106,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 @@ -275,6 +291,27 @@ 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, +}: Readonly) { + const { t } = useTranslation(["common"]); + const { data: config } = useSWR("config"); + const timeFormat = useTimeFormat(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; + } + + // the picker edits a timestamp in the configured UI timezone, + // but the stored value remains a unix timestamp + return (timezoneOffset - localTimeOffset) * 60; + }, [timezoneOffset, localTimeOffset]); + + const displayTimestamp = useMemo( + () => timestamp + offsetDeltaSeconds, + [timestamp, offsetDeltaSeconds], + ); + + const formattedTimestamp = useFormattedTimestamp( + displayTimestamp, + timeFormat == "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) => { + // convert the edited display time back into the underlying Unix timestamp + 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( + Number.parseInt(hour), + Number.parseInt(minute), + Number.parseInt(second ?? "0"), + 0, + ); + setFromDisplayDate(nextTimestamp); + }} + /> + + +
+
+ ); +} diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 7d762912c..7dfbf3bf3 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -216,7 +216,11 @@ export default function HlsVideoPlayer({ const [tallCamera, setTallCamera] = useState(false); const [isPlaying, setIsPlaying] = useState(true); - const [muted, setMuted] = useUserPersistence("hlsPlayerMuted", true); + const [persistedMuted, setPersistedMuted] = useUserPersistence( + "hlsPlayerMuted", + true, + ); + const [temporaryMuted, setTemporaryMuted] = useState(false); const [volume, setVolume] = useOverlayState("playerVolume", 1.0); const [defaultPlaybackRate] = useUserPersistence("playbackRate", 1); const [playbackRate, setPlaybackRate] = useOverlayState( @@ -232,6 +236,16 @@ export default function HlsVideoPlayer({ height: number; }>({ width: 0, height: 0 }); + const muted = persistedMuted || temporaryMuted; + + const onSetMuted = useCallback( + (muted: boolean) => { + setTemporaryMuted(false); + setPersistedMuted(muted); + }, + [setPersistedMuted], + ); + useEffect(() => { if (!isDesktop) { return; @@ -297,7 +311,7 @@ export default function HlsVideoPlayer({ fullscreen: supportsFullscreen, }} setControlsOpen={setControlsOpen} - setMuted={(muted) => setMuted(muted)} + setMuted={onSetMuted} playbackRate={playbackRate ?? 1} hotKeys={hotKeys} onPlayPause={onPlayPause} @@ -404,9 +418,20 @@ export default function HlsVideoPlayer({ : undefined } onVolumeChange={() => { - setVolume(videoRef.current?.volume ?? 1.0, true); - if (!frigateControls) { - setMuted(videoRef.current?.muted); + if (!videoRef.current) { + return; + } + + setVolume(videoRef.current.volume ?? 1.0, true); + + if (frigateControls) { + if (videoRef.current.muted && !persistedMuted) { + setTemporaryMuted(true); + } else if (!videoRef.current.muted && temporaryMuted) { + setTemporaryMuted(false); + } + } else { + setPersistedMuted(videoRef.current.muted); } }} onPlay={() => { diff --git a/web/src/components/player/dynamic/DynamicVideoController.ts b/web/src/components/player/dynamic/DynamicVideoController.ts index e9da0064d..151ea4022 100644 --- a/web/src/components/player/dynamic/DynamicVideoController.ts +++ b/web/src/components/player/dynamic/DynamicVideoController.ts @@ -6,6 +6,7 @@ import { calculateInpointOffset, calculateSeekPosition, } from "@/utils/videoUtil"; +import { playWithTemporaryMuteFallback } from "@/utils/videoUtil.ts"; type PlayerMode = "playback" | "scrubbing"; @@ -107,7 +108,7 @@ export class DynamicVideoController { return new Promise((resolve) => { const onSeekedHandler = () => { this.playerController.removeEventListener("seeked", onSeekedHandler); - this.playerController.play(); + playWithTemporaryMuteFallback(this.playerController); resolve(undefined); }; diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index c4a1f1c6d..2bb37fa98 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -21,12 +21,17 @@ import { getBeginningOfDayTimestamp, getEndOfDayTimestamp, } from "@/utils/dateUtil"; +import { + parseRecordingReviewLink, + 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, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import useSWR from "swr"; export default function Events() { @@ -127,6 +132,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 +155,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 +243,51 @@ export default function Events() { [recording, setRecording, setReviewFilter], ); + useSearchEffect(RECORDING_REVIEW_LINK_PARAM, (reviewLinkValue: string) => { + if (!config) { + return false; + } + + const reviewLink = parseRecordingReviewLink(reviewLinkValue); + + if (!reviewLink) { + toast.error(t("recordings.invalidSharedLink"), { + position: "top-center", + }); + return true; + } + + const validCamera = + config.cameras[reviewLink.camera] && + allowedCameras.includes(reviewLink.camera); + + if (!validCamera) { + toast.error(t("recordings.invalidSharedCamera"), { + position: "top-center", + }); + return true; + } + + setReviewFilter({ + ...reviewFilter, + ...getReviewDayBounds(new Date(reviewLink.timestamp * 1000)), + }); + 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, + navigationSource: "shared-link", + }, + true, + ); + + return true; + }); + // review paging const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000)); diff --git a/web/src/types/record.ts b/web/src/types/record.ts index 107a8d86e..d8fd163bf 100644 --- a/web/src/types/record.ts +++ b/web/src/types/record.ts @@ -40,6 +40,7 @@ export type RecordingStartingPoint = { startTime: number; severity: ReviewSeverity; timelineType?: TimelineType; + navigationSource?: "shared-link"; }; export type RecordingPlayerError = "stalled" | "startup"; diff --git a/web/src/utils/recordingReviewUrl.ts b/web/src/utils/recordingReviewUrl.ts new file mode 100644 index 000000000..0bc8960d0 --- /dev/null +++ b/web/src/utils/recordingReviewUrl.ts @@ -0,0 +1,56 @@ +import { baseUrl } from "@/api/baseUrl.ts"; + +export const RECORDING_REVIEW_LINK_PARAM = "timestamp"; + +export type RecordingReviewLinkState = { + camera: string; + timestamp: number; +}; + +export function parseRecordingReviewLink( + value: string | null, +): RecordingReviewLinkState | undefined { + if (!value) { + return undefined; + } + + const separatorIndex = value.lastIndexOf("_"); + + if (separatorIndex <= 0 || separatorIndex == value.length - 1) { + return undefined; + } + + const camera = value.slice(0, separatorIndex); + const timestamp = value.slice(separatorIndex + 1); + + if (!camera || !timestamp) { + return undefined; + } + + const parsedTimestamp = Number(timestamp); + const now = Math.floor(Date.now() / 1000); + + if (!Number.isFinite(parsedTimestamp) || parsedTimestamp <= 0) { + return undefined; + } + + return { + camera, + // clamp future timestamps to now + timestamp: Math.min(Math.floor(parsedTimestamp), now), + }; +} + +export function createRecordingReviewUrl( + pathname: string, + state: RecordingReviewLinkState, +): string { + const url = new URL(baseUrl); + url.pathname = pathname.startsWith("/") ? pathname : `/${pathname}`; + url.searchParams.set( + RECORDING_REVIEW_LINK_PARAM, + `${state.camera}_${Math.floor(state.timestamp)}`, + ); + + return url.toString(); +} diff --git a/web/src/utils/videoUtil.ts b/web/src/utils/videoUtil.ts index 0b09ac061..d6ab203e9 100644 --- a/web/src/utils/videoUtil.ts +++ b/web/src/utils/videoUtil.ts @@ -78,3 +78,20 @@ export function calculateSeekPosition( return seekSeconds >= 0 ? seekSeconds : undefined; } + +/** + * Attempts to play the video, and if it fails due to a NotAllowedError (often caused by browser autoplay restrictions), + * it temporarily mutes the video and tries to play again. + * @param video - The HTMLVideoElement to play + */ +export function playWithTemporaryMuteFallback(video: HTMLVideoElement) { + return video.play().catch((error: { name?: string }) => { + if (error.name === "NotAllowedError" && !video.muted) { + video.muted = true; + + return video.play().catch(() => undefined); + } + + throw error; + }); +} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index a3466b256..30caac95f 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -42,7 +42,7 @@ import { isTablet, } from "react-device-detect"; import { IoMdArrowRoundBack } from "react-icons/io"; -import { useNavigate } from "react-router-dom"; +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 +77,9 @@ import { GenAISummaryDialog, GenAISummaryChip, } from "@/components/overlay/chip/GenAISummaryChip"; +import ShareTimestampDialog from "@/components/overlay/ShareTimestampDialog"; +import { shareOrCopy } from "@/utils/browserUtil"; +import { createRecordingReviewUrl } from "@/utils/recordingReviewUrl"; const DATA_REFRESH_TIME = 600000; // 10 minutes @@ -104,9 +107,10 @@ 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(); const contentRef = useRef(null); // recordings summary @@ -205,6 +209,16 @@ export function RecordingView({ const [debugReplayMode, setDebugReplayMode] = useState("none"); const [debugReplayRange, setDebugReplayRange] = useState(); + const [shareTimestampOpen, setShareTimestampOpen] = useState(false); + const [shareTimestampAtOpen, setShareTimestampAtOpen] = useState( + Math.floor(startTime), + ); + const [shareTimestampOption, setShareTimestampOption] = useState< + "current" | "custom" + >("current"); + const [customShareTimestamp, setCustomShareTimestamp] = useState( + Math.floor(startTime), + ); // move to next clip @@ -317,6 +331,34 @@ export function RecordingView({ [currentTimeRange, updateSelectedSegment], ); + const onShareReviewLink = useCallback( + (timestamp: number) => { + const reviewUrl = createRecordingReviewUrl(location.pathname, { + camera: mainCamera, + timestamp: Math.floor(timestamp), + }); + + shareOrCopy( + reviewUrl, + t("recording.shareTimestamp.shareTitle", { + ns: "components/dialog", + camera: mainCamera, + }), + ); + }, + [location.pathname, mainCamera, t], + ); + + const handleBack = useCallback(() => { + // if we came from a direct share link, there is no history to go back to, so navigate to the homepage instead + if (recording?.navigationSource === "shared-link") { + navigate("/"); + return; + } + + navigate(-1); + }, [navigate, recording?.navigationSource]); + useEffect(() => { if (!scrubbing) { if (Math.abs(currentTime - playerTime) > 10) { @@ -567,7 +609,7 @@ export function RecordingView({ className="flex items-center gap-2.5 rounded-lg" aria-label={t("label.back", { ns: "common" })} size="sm" - onClick={() => navigate(-1)} + onClick={handleBack} > {isDesktop && ( @@ -663,8 +705,28 @@ export function RecordingView({ setMotionOnly={() => {}} /> )} + {isDesktop && ( + + )} {isDesktop && ( { + const initialTimestamp = Math.floor(currentTime); + + setShareTimestampAtOpen(initialTimestamp); + setShareTimestampOption("current"); + setCustomShareTimestamp(initialTimestamp); + setShareTimestampOpen(true); + }} onDebugReplayClick={() => { const now = new Date(timeRange.before * 1000); now.setHours(now.getHours() - 1); @@ -744,6 +806,7 @@ export function RecordingView({ mainControllerRef.current?.pause(); } }} + onShareTimestamp={onShareReviewLink} onUpdateFilter={updateFilter} setRange={setExportRange} setMode={setExportMode}