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
+66 -3
View File
@@ -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<FrigateConfig>("config");
const navigate = useNavigate();
const location = useLocation();
const contentRef = useRef<HTMLDivElement | null>(null);
// recordings summary
@@ -205,6 +209,16 @@ export function RecordingView({
const [debugReplayMode, setDebugReplayMode] = useState<ExportMode>("none");
const [debugReplayRange, setDebugReplayRange] = useState<TimeRange>();
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}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
@@ -663,8 +705,28 @@ export function RecordingView({
setMotionOnly={() => {}}
/>
)}
{isDesktop && (
<ShareTimestampDialog
currentTime={shareTimestampAtOpen}
open={shareTimestampOpen}
onOpenChange={setShareTimestampOpen}
selectedOption={shareTimestampOption}
setSelectedOption={setShareTimestampOption}
customTimestamp={customShareTimestamp}
setCustomTimestamp={setCustomShareTimestamp}
onShareTimestamp={onShareReviewLink}
/>
)}
{isDesktop && (
<ActionsDropdown
onShareTimestampClick={() => {
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}