import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, 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 { Card } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { ObjectType } from "@/types/ws"; import { useJobStatus } from "@/api/ws"; import WsMessageFeed from "@/components/ws/WsMessageFeed"; import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate"; import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu"; import { LuSquare } from "react-icons/lu"; import { MdReplay } from "react-icons/md"; import { isDesktop, isMobile } from "react-device-detect"; import Logo from "@/components/Logo"; import { Separator } from "@/components/ui/separator"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { useConfigSchema } from "@/hooks/use-config-schema"; import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer"; import { IoMdArrowRoundBack } from "react-icons/io"; type DebugReplayStatus = { active: boolean; replay_camera: string | null; source_camera: string | null; start_time: number | null; end_time: number | null; live_ready: boolean; }; type DebugReplayJobResults = { current_step: "preparing_clip" | "starting_camera" | null; progress_percent: number | null; source_camera: string | null; replay_camera_name: string | null; start_ts: number | null; end_ts: number | null; }; 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", }; export default function Replay() { const { t } = useTranslation(["views/replay", "views/settings", "common"]); const navigate = useNavigate(); const { getLocaleDocUrl } = useDocDomain(); const { data: status, mutate: refreshStatus, isLoading, } = useSWR("debug_replay/status", { refreshInterval: 1000, }); const { payload: replayJob } = useJobStatus("debug_replay"); const configSchema = useConfigSchema(); 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]); 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(() => { refreshStatus(); }) .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); }); }, [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); // debug draw const containerRef = useRef(null); const [debugDraw, setDebugDraw] = useState(false); // 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 (
); } // Startup error (job failed). Only show when status.active is also true so // we don't surface stale failed jobs after a session ended cleanly. if (replayJob?.status === "failed" && status?.active) { return (
{t("page.startError.title")} {replayJob.error_message && (

{replayJob.error_message}

)}
); } // No active session. Also covers the brief window between the runner // pushing job.status = "cancelled" via WS and the next SWR refresh // flipping status.active to false — without this, render falls through // to the full replay UI and you see a flash of it before stop completes. if (!status?.active || replayJob?.status === "cancelled") { return (
{t("page.noSession")}

{t("page.noSessionDesc")}

); } // Startup in progress (job is running). The session is active but the // replay camera isn't ready yet; show progress / phase from the job. const startupStep = replayJob?.status === "running" ? (replayJob.results?.current_step ?? null) : null; if (startupStep === "preparing_clip" || startupStep === "starting_camera") { const phaseTitle = startupStep === "preparing_clip" ? t("page.preparingClip") : t("page.startingCamera"); const progressPercent = replayJob?.results?.progress_percent ?? null; const showProgressBar = startupStep === "preparing_clip" && progressPercent != null; return (
{showProgressBar ? (
{Math.round(progressPercent ?? 0)}%
) : ( )} {phaseTitle} {startupStep === "preparing_clip" && (

{t("page.preparingClipDesc")}

)}
); } 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 && (
{status.live_ready ? ( <> {debugDraw && ( )} ) : (
{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) } />
); })} {isDesktop && ( <>
{t("button.info", { ns: "common" })}
{t("debug.objectShapeFilterDrawing.tips", { ns: "views/settings", })}
{t("readTheDocumentation", { ns: "common", })}
{t("debug.objectShapeFilterDrawing.desc", { ns: "views/settings", })}
{ setDebugDraw(isChecked); }} />
)}
{t("page.configuration")} {t("page.configurationDesc")} {configSchema == null ? (
) : (
)}
); } type ObjectListProps = { cameraConfig?: CameraConfig; objects?: ObjectType[]; config?: FrigateConfig; }; function ObjectList({ cameraConfig, objects, config }: ObjectListProps) { const { t } = useTranslation(["views/settings", "common"]); 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-5 text-white")}
{getTranslatedLabel(obj.label)}

{t("debug.objectShapeFilterDrawing.score", { ns: "views/settings", })}

{obj.score ? (obj.score * 100).toFixed(1).toString() : "-"}%

{t("debug.objectShapeFilterDrawing.ratio", { ns: "views/settings", })}

{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}

{t("debug.objectShapeFilterDrawing.area", { ns: "views/settings", })}

{obj.area && cameraConfig ? (
{t("information.pixels", { ns: "common", area: obj.area, })}
{( (obj.area / (cameraConfig.detect.width * cameraConfig.detect.height)) * 100 ).toFixed(2)} %
) : ( "-" )}
); })}
); }