import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Trans, useTranslation } from "react-i18next"; import useSWR from "swr"; import axios from "axios"; import { toast } from "sonner"; import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage"; import { Button, buttonVariants } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { useCameraActivity } from "@/hooks/use-camera-activity"; import { cn } from "@/lib/utils"; import Heading from "@/components/ui/heading"; import { Toaster } from "@/components/ui/sonner"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { getIconForLabel } from "@/utils/iconUtil"; import { getTranslatedLabel } from "@/utils/i18n"; import { ObjectType } from "@/types/ws"; import WsMessageFeed from "@/components/ws/WsMessageFeed"; import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate"; import { LuInfo, LuSettings } from "react-icons/lu"; import { LuSquare } from "react-icons/lu"; import { MdReplay } from "react-icons/md"; import { isMobile } from "react-device-detect"; import Logo from "@/components/Logo"; type DebugReplayStatus = { active: boolean; replay_camera: string | null; source_camera: string | null; start_time: number | null; end_time: number | null; live_ready: boolean; }; type DebugOptions = { bbox: boolean; timestamp: boolean; zones: boolean; mask: boolean; motion: boolean; regions: boolean; paths: boolean; }; const DEFAULT_OPTIONS: DebugOptions = { bbox: true, timestamp: false, zones: false, mask: false, motion: true, regions: false, paths: false, }; const DEBUG_OPTION_KEYS: (keyof DebugOptions)[] = [ "bbox", "timestamp", "zones", "mask", "motion", "regions", "paths", ]; const DEBUG_OPTION_I18N_KEY: Record = { bbox: "boundingBoxes", timestamp: "timestamp", zones: "zones", mask: "mask", motion: "motion", regions: "regions", paths: "paths", }; const REPLAY_INIT_SKELETON_TIMEOUT_MS = 8000; export default function Replay() { const { t } = useTranslation(["views/replay", "views/settings", "common"]); const navigate = useNavigate(); const { data: status, mutate: refreshStatus, isLoading, } = useSWR("debug_replay/status", { refreshInterval: 1000, }); const [isInitializing, setIsInitializing] = useState(true); // Refresh status immediately on mount to avoid showing "no session" briefly useEffect(() => { const initializeStatus = async () => { await refreshStatus(); setIsInitializing(false); }; initializeStatus(); }, [refreshStatus]); useEffect(() => { if (status?.live_ready) { setShowReplayInitSkeleton(false); } }, [status?.live_ready]); const [options, setOptions] = useState(DEFAULT_OPTIONS); const [isStopping, setIsStopping] = useState(false); const [configDialogOpen, setConfigDialogOpen] = useState(false); const searchParams = useMemo(() => { const params = new URLSearchParams(); for (const key of DEBUG_OPTION_KEYS) { params.set(key, options[key] ? "1" : "0"); } return params; }, [options]); const handleSetOption = useCallback( (key: keyof DebugOptions, value: boolean) => { setOptions((prev) => ({ ...prev, [key]: value })); }, [], ); const handleStop = useCallback(() => { setIsStopping(true); axios .post("debug_replay/stop") .then(() => { toast.success(t("dialog.toast.stopped"), { position: "top-center", }); refreshStatus(); navigate("/review"); }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(t("dialog.toast.stopError", { error: errorMessage }), { position: "top-center", }); }) .finally(() => { setIsStopping(false); }); }, [navigate, refreshStatus, t]); // Camera activity for the replay camera const { data: config } = useSWR("config", { revalidateOnFocus: false, }); const replayCameraName = status?.replay_camera ?? ""; const replayCameraConfig = replayCameraName ? config?.cameras?.[replayCameraName] : undefined; const { objects } = useCameraActivity(replayCameraConfig); const [showReplayInitSkeleton, setShowReplayInitSkeleton] = useState(false); useEffect(() => { if (!status?.active || !status.replay_camera) { setShowReplayInitSkeleton(false); return; } setShowReplayInitSkeleton(true); const timeout = window.setTimeout(() => { setShowReplayInitSkeleton(false); }, REPLAY_INIT_SKELETON_TIMEOUT_MS); return () => { window.clearTimeout(timeout); }; }, [status?.active, status?.replay_camera]); useEffect(() => { if (status?.live_ready) { setShowReplayInitSkeleton(false); } }, [status?.live_ready]); // Format time range for display const timeRangeDisplay = useMemo(() => { if (!status?.start_time || !status?.end_time) return ""; const start = new Date(status.start_time * 1000).toLocaleString(); const end = new Date(status.end_time * 1000).toLocaleString(); return `${start} — ${end}`; }, [status]); // Show loading state if (isInitializing || (isLoading && !status?.active)) { return (
); } // No active session if (!status?.active) { return (
{t("page.noSession")}

{t("page.noSessionDesc")}

); } return (
{/* Top bar */}
{isMobile && ( )}
{t("page.confirmStop.title")} {t("page.confirmStop.description")} {t("page.confirmStop.cancel")} {t("page.confirmStop.confirm")}
{/* Main content */}
{/* Camera feed */}
{isStopping ? (
{t("page.stoppingReplay")}
) : ( status.replay_camera && (
{showReplayInitSkeleton && (
{t("page.initializingReplay")}
)}
) )}
{/* Side panel */}
{t("title")}
{status.source_camera} {timeRangeDisplay && ( <> {timeRangeDisplay} )}

{t("description")}

{t("debug.debugging", { ns: "views/settings" })} {t("page.objects")} {t("websocket_messages")}
{DEBUG_OPTION_KEYS.map((key) => { const i18nKey = DEBUG_OPTION_I18N_KEY[key]; return (
{(key === "bbox" || key === "motion" || key === "regions" || key === "paths") && (
{t("button.info", { ns: "common" })}
{key === "bbox" ? ( <>

{t( "debug.boundingBoxes.colors.label", { ns: "views/settings", }, )}

    debug.boundingBoxes.colors.info
) : ( {`debug.${i18nKey}.tips`} )}
)}
{t(`debug.${i18nKey}.desc`, { ns: "views/settings", })}
handleSetOption(key, checked) } />
); })}
{t("page.configuration")} {t("page.configurationDesc")}
); } type ObjectListProps = { cameraConfig?: CameraConfig; objects?: ObjectType[]; config?: FrigateConfig; }; function ObjectList({ cameraConfig, objects, config }: ObjectListProps) { const { t } = useTranslation(["views/settings"]); const colormap = useMemo(() => { if (!config) { return; } return config.model?.colormap; }, [config]); const getColorForObjectName = useCallback( (objectName: string) => { return colormap && colormap[objectName] ? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})` : "rgb(128, 128, 128)"; }, [colormap], ); if (!objects || objects.length === 0) { return (
{t("debug.noObjects", { ns: "views/settings" })}
); } return (
{objects.map((obj: ObjectType) => { return (
{getIconForLabel(obj.label, "object", "size-4 text-white")}
{getTranslatedLabel(obj.label)}
{t("debug.objectShapeFilterDrawing.score", { ns: "views/settings", })} : {obj.score ? (obj.score * 100).toFixed(1) : "-"}%
{obj.ratio && (
{t("debug.objectShapeFilterDrawing.ratio", { ns: "views/settings", })} : {obj.ratio.toFixed(2)}
)} {obj.area && cameraConfig && (
{t("debug.objectShapeFilterDrawing.area", { ns: "views/settings", })} : {obj.area} px ( {( (obj.area / (cameraConfig.detect.width * cameraConfig.detect.height)) * 100 ).toFixed(2)} %)
)}
); })}
); }