import { useCallback, useMemo, useState } from "react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "../ui/dialog"; import { Label } from "../ui/label"; import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { Button } from "../ui/button"; import { ExportMode } from "@/types/filter"; import { FaArrowDown, FaArrowRight, FaCalendarAlt } from "react-icons/fa"; import axios from "axios"; import { toast } from "sonner"; import { Input } from "../ui/input"; import { TimeRange } from "@/types/timeline"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { TimezoneAwareCalendar } from "./ReviewActivityCalendar"; import { SelectSeparator } from "../ui/select"; import { isDesktop, isIOS, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; import { getUTCOffset } from "@/utils/dateUtil"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; import { useTranslation } from "react-i18next"; const EXPORT_OPTIONS = [ "1", "4", "8", "12", "24", "timeline", "custom", ] as const; type ExportOption = (typeof EXPORT_OPTIONS)[number]; type ExportDialogProps = { camera: string; latestTime: number; currentTime: number; range?: TimeRange; mode: ExportMode; showPreview: boolean; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; setShowPreview: (showPreview: boolean) => void; }; export default function ExportDialog({ camera, latestTime, currentTime, range, mode, showPreview, setRange, setMode, setShowPreview, }: ExportDialogProps) { const { t } = useTranslation(["components/dialog"]); const [name, setName] = useState(""); const onStartExport = useCallback(() => { if (!range) { toast.error(t("export.toast.error.noVaildTimeSelected"), { position: "top-center", }); return; } if (range.before < range.after) { toast.error(t("export.toast.error.endTimeMustAfterStartTime"), { position: "top-center", }); return; } axios .post( `export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`, { playback: "realtime", name, }, ) .then((response) => { if (response.status == 200) { toast.success(t("export.toast.success"), { position: "top-center", }); setName(""); setRange(undefined); setMode("none"); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error( t("export.toast.error.failed", { error: errorMessage, }), { position: "top-center" }, ); }); }, [camera, name, range, setRange, setName, setMode, t]); const handleCancel = useCallback(() => { setName(""); setMode("none"); setRange(undefined); }, [setMode, setRange]); const Overlay = isDesktop ? Dialog : Drawer; const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; const Content = isDesktop ? DialogContent : DrawerContent; return ( <> setShowPreview(true)} onSave={() => onStartExport()} onCancel={handleCancel} /> { if (!open) { setMode("none"); } }} > ); } type ExportContentProps = { latestTime: number; currentTime: number; range?: TimeRange; name: string; onStartExport: () => void; setName: (name: string) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; onCancel: () => void; }; export function ExportContent({ latestTime, currentTime, range, name, onStartExport, setName, setRange, setMode, onCancel, }: ExportContentProps) { const { t } = useTranslation(["components/dialog"]); const [selectedOption, setSelectedOption] = useState("1"); const onSelectTime = useCallback( (option: ExportOption) => { setSelectedOption(option); const now = new Date(latestTime * 1000); let start = 0; switch (option) { case "1": now.setHours(now.getHours() - 1); start = now.getTime() / 1000; break; case "4": now.setHours(now.getHours() - 4); start = now.getTime() / 1000; break; case "8": now.setHours(now.getHours() - 8); start = now.getTime() / 1000; break; case "12": now.setHours(now.getHours() - 12); start = now.getTime() / 1000; break; case "24": now.setHours(now.getHours() - 24); start = now.getTime() / 1000; break; case "custom": start = latestTime - 3600; break; } setRange({ before: latestTime, after: start, }); }, [latestTime, setRange], ); return (
{isDesktop && ( <> {t("menu.export", { ns: "common" })} )} onSelectTime(value as ExportOption)} > {EXPORT_OPTIONS.map((opt) => { return (
); })}
{selectedOption == "custom" && ( )} setName(e.target.value)} /> {isDesktop && }
{t("button.cancel", { ns: "common" })}
); } type CustomTimeSelectorProps = { latestTime: number; range?: TimeRange; setRange: (range: TimeRange | undefined) => void; }; function CustomTimeSelector({ latestTime, range, setRange, }: CustomTimeSelectorProps) { const { t } = useTranslation(["components/dialog"]); const { data: config } = useSWR("config"); // times 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 startTime = useMemo(() => { let time = range?.after || latestTime - 3600; if (timezoneOffset) { time = time + (timezoneOffset - localTimeOffset) * 60; } return time; }, [range, latestTime, timezoneOffset, localTimeOffset]); const endTime = useMemo(() => { let time = range?.before || latestTime; if (timezoneOffset) { time = time + (timezoneOffset - localTimeOffset) * 60; } return time; }, [range, latestTime, timezoneOffset, localTimeOffset]); const formattedStart = useFormattedTimestamp( startTime, config?.ui.time_format == "24hour" ? t("time.formattedTimestamp.24hour", { ns: "common" }) : t("time.formattedTimestamp.12hour", { ns: "common" }), ); const formattedEnd = useFormattedTimestamp( endTime, config?.ui.time_format == "24hour" ? t("time.formattedTimestamp.24hour", { ns: "common" }) : t("time.formattedTimestamp.12hour", { ns: "common" }), ); const startClock = useMemo(() => { const date = new Date(startTime * 1000); return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; }, [startTime]); const endClock = useMemo(() => { const date = new Date(endTime * 1000); return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`; }, [endTime]); // calendars const [startOpen, setStartOpen] = useState(false); const [endOpen, setEndOpen] = useState(false); return (
{ if (!open) { setStartOpen(false); } }} > { if (!day) { return; } setRange({ before: endTime, after: day.getTime() / 1000 + 1, }); }} /> { const clock = e.target.value; const [hour, minute, second] = isIOS ? [...clock.split(":"), "00"] : clock.split(":"); const start = new Date(startTime * 1000); start.setHours( parseInt(hour), parseInt(minute), parseInt(second ?? 0), 0, ); setRange({ before: endTime, after: start.getTime() / 1000, }); }} /> { if (!open) { setEndOpen(false); } }} > { if (!day) { return; } setRange({ after: startTime, before: day.getTime() / 1000, }); }} /> { const clock = e.target.value; const [hour, minute, second] = isIOS ? [...clock.split(":"), "00"] : clock.split(":"); const end = new Date(startTime * 1000); end.setHours( parseInt(hour), parseInt(minute), parseInt(second ?? 0), 0, ); setRange({ before: end.getTime() / 1000, after: startTime, }); }} />
); } type ExportPreviewDialogProps = { camera: string; range?: TimeRange; showPreview: boolean; setShowPreview: (showPreview: boolean) => void; }; export function ExportPreviewDialog({ camera, range, showPreview, setShowPreview, }: ExportPreviewDialogProps) { const { t } = useTranslation(["components/dialog"]); if (!range) { return null; } const source = `${baseUrl}vod/${camera}/start/${range.after}/end/${range.before}/index.m3u8`; return ( {t("export.fromTimeline.previewExport")} {t("export.fromTimeline.previewExport")} ); }