Feature: Share Timestamped URL for Camera Footage History (#22537)

* Initial copy timestamp url implementation

* revise url format

* Implement share timestamp dialog

* Use translations

* Add comments

* Add validations to shared link

* Switch to searchEffect implementation

* Add missing accessibility related dialog description

* Change URL format to unix timestamps

* Remove unnecessary useEffect

* Remove duplicated dialog title

* Fixes/improvements based off PR review comments

* Add missing cancel button & separators to dialog

* Make share description clearer

* Bugfix: guard against showing toasts twice
Because this effect ends up running multiple times

* Clamp future timestamps to now

* Revert "Bugfix: guard against showing toasts twice"

This reverts commit 99fa5e1dee.

* Use normal separator

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Fixes based off PR review comments

* Bugfix: Share dialog was not receiving the player timestamp after removing key that triggered remounts

* Defer `setRecording` and return true from hook for cleanup

* Remove timeout defer hack in favor of refactored hook

* Attempt to replay video muted on NotAllowedError

* Use separate persistent mute and temporary forced mute states

* Align cancel button with other dialogs

* Prevent wrapping on dialog title

* Remove extra "back" button on mobile drawer

* Fix back navigation when coming from direct shared timestamp links

* Use new timeformat hook

* Simplify dialog radio buttons

* Apply suggestions from code review

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Otto
2026-04-20 06:35:25 -06:00
committed by GitHub
co-authored by Josh Hawkins
parent d7f42735fc
commit 423ee2fe72
12 changed files with 678 additions and 17 deletions
+59 -4
View File
@@ -21,6 +21,10 @@ 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";
@@ -28,6 +32,7 @@ import { useFrigateReviews } from "@/api/ws";
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() {
@@ -128,6 +133,14 @@ export default function Events() {
const [notificationTab, setNotificationTab] =
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) => {
if (tab === "timeline" || tab === "events" || tab === "detail") {
setNotificationTab(tab as TimelineType);
@@ -143,10 +156,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,
@@ -234,6 +244,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));