"use client";
import {
CircleAlert,
ExternalLink,
KeyRound,
LoaderCircle,
Lock,
LogIn,
RefreshCw,
Trash2,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import {
useCameraInfo,
useCameraSnapshot,
useClearCredentials,
useCredentialStatus,
useResetCamera,
useSaveCredentials,
type CameraProblem,
} from "./camera-queries";
import { cameraStatus, StatusBadge } from "./camera-status";
import { deviceLine, streamSummary } from "./camera-summary";
const DEFAULT_USERNAME = "camera";
/** The camera's own web interface; it can't be framed (X-Frame-Options: SAMEORIGIN). */
function webUiUrl(cam: CameraSummary) {
return `http://${cam.host}${cam.port === 80 ? "" : `:${cam.port}`}/`;
}
function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.ReactNode }) {
return (
{children}
);
}
function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) {
return (
Setup required: camera not activated
-
Open the camera's web page and activate it by
choosing an admin password. Save it in your password manager; this app never stores
it.
-
Enable ONVIF (usually Configuration → Network → Advanced Settings → Integration
Protocol).
-
On the same page, add an ONVIF user named
{DEFAULT_USERNAME} with the{" "}
Operator level.
- Come back here and click Check again, then enter that user's login.
);
}
/** Shows the latest frame; polling itself lives in useCameraSnapshot. */
function Snapshot({ cam, frame, paused }: { cam: CameraSummary; frame?: Blob; paused: boolean }) {
const imgRef = useRef(null);
// Each frame gets one object URL, created and revoked in the same effect so it is freed
// exactly once, including under Strict Mode's double rendering.
useEffect(() => {
const img = imgRef.current;
if (!frame || !img) return;
const url = URL.createObjectURL(frame);
img.src = url;
return () => URL.revokeObjectURL(url);
}, [frame]);
return (
{frame && (
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
![{cam.name]()
)}
{!frame && !paused && (
Loading…
)}
);
}
function CredentialsForm({
cam,
reason,
onSaved,
onCancel,
}: {
cam: CameraSummary;
reason?: string;
/** Called after a save or clear; the camera's queries have already been reset. */
onSaved: () => void;
onCancel?: () => void;
}) {
const status = useCredentialStatus(cam.id);
const save = useSaveCredentials(cam.id);
const clear = useClearCredentials(cam.id);
// null until the user types, so the saved username (never the password) can prefill it.
const [typedUsername, setTypedUsername] = useState(null);
const [password, setPassword] = useState("");
const username = typedUsername ?? status.data?.username ?? DEFAULT_USERNAME;
function submit(e: React.FormEvent) {
e.preventDefault();
save.mutate(
{ username, password },
{
onSuccess: () => {
setPassword("");
onSaved();
},
},
);
}
const error = save.error ?? clear.error;
const busy = save.isPending || clear.isPending;
return (
);
}
export default function CameraCard({
cam,
intervalMs,
}: {
cam: CameraSummary;
intervalMs: number;
}) {
const info = useCameraInfo(cam.id);
const snapshot = useCameraSnapshot(cam.id, intervalMs, info.isSuccess);
const resetCamera = useResetCamera(cam.id);
const [editingLogin, setEditingLogin] = useState(false);
const problem: CameraProblem | null = info.error ?? snapshot.error;
function retry() {
setEditingLogin(false);
resetCamera();
}
const device = info.data ? deviceLine(info.data) : null;
const summary = info.data ? streamSummary(info.data.profiles) : null;
const showSetup = problem?.kind === "inactive";
const showLogin = !showSetup && (problem?.kind === "auth" || editingLogin);
return (
{cam.name ?? info.data?.model ?? "Unnamed camera"}
{cam.host}:{cam.port}
Web page
{!showLogin && !showSetup && (
)}
{device &&
{device}
}
{summary &&
{summary}
}
{cam.location && (
{cam.location}
)}
{showSetup && }
{showLogin && (
setEditingLogin(false)}
onCancel={problem?.kind === "auth" ? undefined : () => setEditingLogin(false)}
/>
)}
{problem?.kind === "signed-out" && (
)}
{problem?.kind === "error" && (
Stopped: {problem.message}
)}
{info.data && info.data.profiles.length > 0 && (
| Profile |
Codec |
Resolution |
FPS |
{info.data.profiles.map((p) => (
| {p.name ?? p.token} |
{p.encoding ?? "?"} |
{p.width && p.height ? `${p.width}×${p.height}` : "?"} |
{p.fps ?? "?"} |
))}
)}
);
}