mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-03 22:04:53 +03:00
Initial copy timestamp url implementation
This commit is contained in:
parent
722ef6a1fe
commit
ec3fc782a8
@ -21,16 +21,24 @@ import {
|
|||||||
getBeginningOfDayTimestamp,
|
getBeginningOfDayTimestamp,
|
||||||
getEndOfDayTimestamp,
|
getEndOfDayTimestamp,
|
||||||
} from "@/utils/dateUtil";
|
} from "@/utils/dateUtil";
|
||||||
|
import {
|
||||||
|
parseRecordingReviewLink,
|
||||||
|
RECORDING_REVIEW_START_PARAM,
|
||||||
|
} from "@/utils/recordingReviewUrl";
|
||||||
import EventView from "@/views/events/EventView";
|
import EventView from "@/views/events/EventView";
|
||||||
import MotionSearchView from "@/views/motion-search/MotionSearchView";
|
import MotionSearchView from "@/views/motion-search/MotionSearchView";
|
||||||
import { RecordingView } from "@/views/recording/RecordingView";
|
import { RecordingView } from "@/views/recording/RecordingView";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
export default function Events() {
|
export default function Events() {
|
||||||
const { t } = useTranslation(["views/events"]);
|
const { t } = useTranslation(["views/events"]);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@ -127,6 +135,14 @@ export default function Events() {
|
|||||||
const [notificationTab, setNotificationTab] =
|
const [notificationTab, setNotificationTab] =
|
||||||
useState<TimelineType>("timeline");
|
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) => {
|
useSearchEffect("tab", (tab: string) => {
|
||||||
if (tab === "timeline" || tab === "events" || tab === "detail") {
|
if (tab === "timeline" || tab === "events" || tab === "detail") {
|
||||||
setNotificationTab(tab as TimelineType);
|
setNotificationTab(tab as TimelineType);
|
||||||
@ -142,10 +158,7 @@ export default function Events() {
|
|||||||
const startTime = resp.data.start_time - REVIEW_PADDING;
|
const startTime = resp.data.start_time - REVIEW_PADDING;
|
||||||
const date = new Date(startTime * 1000);
|
const date = new Date(startTime * 1000);
|
||||||
|
|
||||||
setReviewFilter({
|
setReviewFilter(getReviewDayBounds(date));
|
||||||
after: getBeginningOfDayTimestamp(date),
|
|
||||||
before: getEndOfDayTimestamp(date),
|
|
||||||
});
|
|
||||||
setRecording(
|
setRecording(
|
||||||
{
|
{
|
||||||
camera: resp.data.camera,
|
camera: resp.data.camera,
|
||||||
@ -233,6 +246,56 @@ export default function Events() {
|
|||||||
[recording, setRecording, setReviewFilter],
|
[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
|
// review paging
|
||||||
|
|
||||||
const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000));
|
const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000));
|
||||||
|
|||||||
42
web/src/utils/recordingReviewUrl.ts
Normal file
42
web/src/utils/recordingReviewUrl.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
@ -42,7 +42,8 @@ import {
|
|||||||
isTablet,
|
isTablet,
|
||||||
} from "react-device-detect";
|
} from "react-device-detect";
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
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 { Toaster } from "@/components/ui/sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { TimeRange, TimelineType } from "@/types/timeline";
|
import { TimeRange, TimelineType } from "@/types/timeline";
|
||||||
@ -77,6 +78,9 @@ import {
|
|||||||
GenAISummaryDialog,
|
GenAISummaryDialog,
|
||||||
GenAISummaryChip,
|
GenAISummaryChip,
|
||||||
} from "@/components/overlay/chip/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
|
const DATA_REFRESH_TIME = 600000; // 10 minutes
|
||||||
|
|
||||||
@ -107,6 +111,7 @@ export function RecordingView({
|
|||||||
const { t } = useTranslation(["views/events"]);
|
const { t } = useTranslation(["views/events"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// recordings summary
|
// recordings summary
|
||||||
@ -141,6 +146,7 @@ export function RecordingView({
|
|||||||
? startTime
|
? startTime
|
||||||
: timeRange.before - 60,
|
: timeRange.before - 60,
|
||||||
);
|
);
|
||||||
|
const lastAppliedStartTimeRef = useRef(startTime);
|
||||||
|
|
||||||
const mainCameraReviewItems = useMemo(
|
const mainCameraReviewItems = useMemo(
|
||||||
() => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [],
|
() => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [],
|
||||||
@ -317,6 +323,28 @@ export function RecordingView({
|
|||||||
[currentTimeRange, updateSelectedSegment],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!scrubbing) {
|
if (!scrubbing) {
|
||||||
if (Math.abs(currentTime - playerTime) > 10) {
|
if (Math.abs(currentTime - playerTime) > 10) {
|
||||||
@ -663,6 +691,19 @@ export function RecordingView({
|
|||||||
setMotionOnly={() => {}}
|
setMotionOnly={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
aria-label={t("button.copy", { ns: "common" })}
|
||||||
|
size="sm"
|
||||||
|
onClick={onCopyReviewLink}
|
||||||
|
>
|
||||||
|
<LuCopy className="size-4 text-secondary-foreground" />
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("button.copy", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<ActionsDropdown
|
<ActionsDropdown
|
||||||
onDebugReplayClick={() => {
|
onDebugReplayClick={() => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user