frigate/web/src/components/overlay/DebugReplayDialog.tsx
Josh Hawkins 6fdd65ddb5
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
UI tweaks (#23346)
* remove redundant per-view toasters in settings

* add variants to standardize dialog footer button layouts

* remove text-md

this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html

* make wizard footers consistent with dialog footers

* consistent destructive button style

remove text-white from individual buttons and add it to the variant
2026-05-29 16:00:30 -06:00

366 lines
10 KiB
TypeScript

import { useCallback, useState } from "react";
import { baseUrl } from "@/api/baseUrl";
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 axios from "axios";
import { toast } from "sonner";
import { isDesktop } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SelectSeparator } from "../ui/select";
import ActivityIndicator from "../indicators/activity-indicator";
import { LuBug, LuPlay, LuX } from "react-icons/lu";
import { ExportMode } from "@/types/filter";
import { TimeRange } from "@/types/timeline";
import { cn } from "@/lib/utils";
import { CustomTimeSelector } from "./CustomTimeSelector";
const REPLAY_TIME_OPTIONS = ["1", "5", "timeline", "custom"] as const;
type ReplayTimeOption = (typeof REPLAY_TIME_OPTIONS)[number];
type DebugReplayContentProps = {
currentTime: number;
latestTime: number;
range?: TimeRange;
selectedOption: ReplayTimeOption;
isStarting: boolean;
onSelectedOptionChange: (option: ReplayTimeOption) => void;
onStart: () => void;
onCancel: () => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
};
export function DebugReplayContent({
currentTime,
latestTime,
range,
selectedOption,
isStarting,
onSelectedOptionChange,
onStart,
onCancel,
setRange,
setMode,
}: DebugReplayContentProps) {
const { t } = useTranslation(["views/replay"]);
return (
<div className="w-full">
{isDesktop && (
<>
<DialogHeader>
<DialogTitle>{t("dialog.title")}</DialogTitle>
<DialogDescription>{t("dialog.description")}</DialogDescription>
</DialogHeader>
<SelectSeparator className="my-4 bg-secondary" />
</>
)}
{/* Time range */}
<div className="mt-4 flex flex-col gap-2">
<RadioGroup
className="mt-2 flex flex-col gap-4"
value={selectedOption}
onValueChange={(value) =>
onSelectedOptionChange(value as ReplayTimeOption)
}
>
{REPLAY_TIME_OPTIONS.map((opt) => (
<div key={opt} className="flex items-center gap-2">
<RadioGroupItem
className={
opt === selectedOption
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id={`replay-${opt}`}
value={opt}
/>
<Label className="cursor-pointer" htmlFor={`replay-${opt}`}>
{opt === "custom"
? t("dialog.preset.custom")
: opt === "timeline"
? t("dialog.preset.timeline")
: t(`dialog.preset.${opt}m`)}
</Label>
</div>
))}
</RadioGroup>
</div>
{/* Custom time inputs */}
{selectedOption === "custom" && (
<CustomTimeSelector
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("dialog.startLabel")}
endLabel={t("dialog.endLabel")}
/>
)}
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter className="mt-3 sm:mt-0">
<Button
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={isStarting}
onClick={() => {
if (selectedOption === "timeline") {
setRange({
after: currentTime - 30,
before: currentTime + 30,
});
setMode("timeline");
} else {
onStart();
}
}}
>
{isStarting ? <ActivityIndicator className="mr-2" /> : null}
{isStarting
? t("dialog.starting")
: selectedOption === "timeline"
? t("dialog.selectFromTimeline")
: t("dialog.startButton")}
</Button>
</DialogFooter>
</div>
);
}
type DebugReplayDialogProps = {
camera: string;
currentTime: number;
latestTime: number;
range?: TimeRange;
mode: ExportMode;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
};
export default function DebugReplayDialog({
camera,
currentTime,
latestTime,
range,
mode,
setRange,
setMode,
}: DebugReplayDialogProps) {
const { t } = useTranslation(["views/replay"]);
const navigate = useNavigate();
const [selectedOption, setSelectedOption] = useState<ReplayTimeOption>("1");
const [isStarting, setIsStarting] = useState(false);
const handleTimeOptionChange = useCallback(
(option: ReplayTimeOption) => {
setSelectedOption(option);
if (option === "custom" || option === "timeline") {
return;
}
const minutes = parseInt(option, 10);
const end = latestTime;
setRange({ after: end - minutes * 60, before: end });
},
[latestTime, setRange],
);
const handleStart = useCallback(() => {
if (!range || range.before <= range.after) {
toast.error(
t("dialog.toast.error", { error: "End time must be after start time" }),
{ position: "top-center" },
);
return;
}
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: camera,
start_time: range.after,
end_time: range.before,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
setMode("none");
setRange(undefined);
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive"), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a
href={`${baseUrl}replay`}
target="_blank"
rel="noopener noreferrer"
>
<Button>{t("dialog.toast.goToReplay")}</Button>
</a>
),
});
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
}, [camera, range, navigate, setMode, setRange, t]);
const handleCancel = useCallback(() => {
setMode("none");
setRange(undefined);
}, [setMode, setRange]);
const Overlay = isDesktop ? Dialog : Drawer;
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
const Content = isDesktop ? DialogContent : DrawerContent;
return (
<>
<SaveDebugReplayOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"}
isStarting={isStarting}
onSave={handleStart}
onCancel={handleCancel}
/>
<Overlay
open={mode == "select"}
onOpenChange={(open) => {
if (!open) {
setMode("none");
}
}}
>
{!isDesktop && (
<Trigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("title")}
size="sm"
onClick={() => {
const end = latestTime;
setRange({ after: end - 60, before: end });
setSelectedOption("1");
setMode("select");
}}
>
<LuBug className="size-5 rounded-md bg-secondary-foreground fill-secondary stroke-secondary p-1" />
{isDesktop && <div className="text-primary">{t("title")}</div>}
</Button>
</Trigger>
)}
<Content
className={
isDesktop
? "max-h-[90dvh] w-auto max-w-2xl overflow-visible sm:rounded-lg md:rounded-2xl"
: "max-h-[75dvh] overflow-y-auto rounded-lg px-4 pb-4 md:rounded-2xl"
}
>
<DebugReplayContent
currentTime={currentTime}
latestTime={latestTime}
range={range}
selectedOption={selectedOption}
isStarting={isStarting}
onSelectedOptionChange={handleTimeOptionChange}
onStart={handleStart}
onCancel={handleCancel}
setRange={setRange}
setMode={setMode}
/>
</Content>
</Overlay>
</>
);
}
type SaveDebugReplayOverlayProps = {
className: string;
show: boolean;
isStarting: boolean;
onSave: () => void;
onCancel: () => void;
};
export function SaveDebugReplayOverlay({
className,
show,
isStarting,
onSave,
onCancel,
}: SaveDebugReplayOverlayProps) {
const { t } = useTranslation(["views/replay"]);
return (
<div className={className}>
<div
className={cn(
"pointer-events-auto flex items-center justify-center gap-2 rounded-lg px-2",
show ? "duration-500 animate-in slide-in-from-top" : "invisible",
"mx-auto mt-5 text-center",
)}
>
<Button
className="flex items-center gap-1 text-primary"
aria-label={t("button.cancel", { ns: "common" })}
size="sm"
disabled={isStarting}
onClick={onCancel}
>
<LuX />
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex items-center gap-1"
aria-label={t("dialog.startButton")}
variant="select"
size="sm"
disabled={isStarting}
onClick={onSave}
>
{isStarting ? <ActivityIndicator className="size-4" /> : <LuPlay />}
{isStarting ? t("dialog.starting") : t("dialog.startButton")}
</Button>
</div>
</div>
);
}