ssửa lại chức năng frontend
This commit is contained in:
@@ -57,6 +57,7 @@ export function useWhepPlayer({
|
||||
pc.oniceconnectionstatechange = null;
|
||||
pc.close();
|
||||
} catch {
|
||||
// Ignore teardown errors when peer connection is already closed.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +67,7 @@ export function useWhepPlayer({
|
||||
v.srcObject = null;
|
||||
v.removeAttribute("src");
|
||||
} catch {
|
||||
// Ignore cleanup errors on detached video element.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +75,7 @@ export function useWhepPlayer({
|
||||
try {
|
||||
await fetch(sessionUrl, { method: "DELETE" });
|
||||
} catch {
|
||||
// Ignore best-effort WHEP session cleanup failures.
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
+159
-44
@@ -2,6 +2,8 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useConfigStore } from "@/stores/configStore";
|
||||
import type { MediaMtxConfigView } from "@/types/api";
|
||||
import { apiJson } from "@/utils/api";
|
||||
|
||||
export default function Settings() {
|
||||
const {
|
||||
@@ -9,21 +11,41 @@ export default function Settings() {
|
||||
isLoading,
|
||||
error,
|
||||
load,
|
||||
addCamera,
|
||||
deleteCamera,
|
||||
setSchedulerEnabled,
|
||||
updateSchedule,
|
||||
} = useConfigStore();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [rtspUrl, setRtspUrl] = useState("");
|
||||
const [mtx, setMtx] = useState<MediaMtxConfigView | null>(null);
|
||||
const [mtxBusy, setMtxBusy] = useState(false);
|
||||
const [mtxError, setMtxError] = useState<string | null>(null);
|
||||
const [restartMsg, setRestartMsg] = useState<string | null>(null);
|
||||
const schedule = config?.schedule;
|
||||
const [weekdaysFrom, setWeekdaysFrom] = useState("18:00");
|
||||
const [weekdaysTo, setWeekdaysTo] = useState("08:00");
|
||||
const [weekendAllDay, setWeekendAllDay] = useState(true);
|
||||
|
||||
const loadMtx = async () => {
|
||||
setMtxBusy(true);
|
||||
setMtxError(null);
|
||||
try {
|
||||
const data = await apiJson<MediaMtxConfigView>("/mediamtx/config", { method: "GET" });
|
||||
setMtx(data);
|
||||
} catch (e) {
|
||||
if (typeof e === "object" && e && "status" in e) {
|
||||
const ex = e as { status: number; bodyText?: string };
|
||||
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
|
||||
} else {
|
||||
setMtxError("failed_to_load_mediamtx_config");
|
||||
}
|
||||
} finally {
|
||||
setMtxBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) void load();
|
||||
void loadMtx();
|
||||
}, [config, load]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,17 +55,31 @@ export default function Settings() {
|
||||
setWeekendAllDay(schedule.weekend_all_day);
|
||||
}, [schedule]);
|
||||
|
||||
const canAdd = useMemo(
|
||||
() => name.trim().length > 0 && rtspUrl.trim().length > 0,
|
||||
[name, rtspUrl]
|
||||
);
|
||||
const canAdd = useMemo(() => rtspUrl.trim().length > 0, [rtspUrl]);
|
||||
|
||||
const onAdd = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canAdd) return;
|
||||
await addCamera({ name: name.trim(), rtsp_url: rtspUrl.trim() });
|
||||
setName("");
|
||||
setRtspUrl("");
|
||||
setMtxBusy(true);
|
||||
setMtxError(null);
|
||||
try {
|
||||
const data = await apiJson<MediaMtxConfigView>("/mediamtx/cameras", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rtsp_url: rtspUrl.trim() }),
|
||||
});
|
||||
setMtx(data);
|
||||
setRtspUrl("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
if (typeof e === "object" && e && "status" in e) {
|
||||
const ex = e as { status: number; bodyText?: string };
|
||||
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
|
||||
} else {
|
||||
setMtxError("failed_to_add_camera");
|
||||
}
|
||||
} finally {
|
||||
setMtxBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveSchedule = async () => {
|
||||
@@ -54,6 +90,69 @@ export default function Settings() {
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = async (name: string) => {
|
||||
setMtxBusy(true);
|
||||
setMtxError(null);
|
||||
try {
|
||||
const data = await apiJson<MediaMtxConfigView>(`/mediamtx/cameras/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setMtx(data);
|
||||
await load();
|
||||
} catch (e) {
|
||||
if (typeof e === "object" && e && "status" in e) {
|
||||
const ex = e as { status: number; bodyText?: string };
|
||||
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
|
||||
} else {
|
||||
setMtxError("failed_to_delete_camera");
|
||||
}
|
||||
} finally {
|
||||
setMtxBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleRecord = async (enabled: boolean) => {
|
||||
setMtxBusy(true);
|
||||
setMtxError(null);
|
||||
try {
|
||||
const data = await apiJson<MediaMtxConfigView>("/mediamtx/recording", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
setMtx(data);
|
||||
} catch (e) {
|
||||
if (typeof e === "object" && e && "status" in e) {
|
||||
const ex = e as { status: number; bodyText?: string };
|
||||
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
|
||||
} else {
|
||||
setMtxError("failed_to_toggle_recording");
|
||||
}
|
||||
} finally {
|
||||
setMtxBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRestartDocker = async () => {
|
||||
setMtxBusy(true);
|
||||
setRestartMsg(null);
|
||||
setMtxError(null);
|
||||
try {
|
||||
const res = await apiJson<{ ok: boolean; output?: string }>("/mediamtx/restart", {
|
||||
method: "POST",
|
||||
});
|
||||
setRestartMsg(res.ok ? "Docker restart thành công" : "Docker restart thất bại");
|
||||
} catch (e) {
|
||||
if (typeof e === "object" && e && "status" in e) {
|
||||
const ex = e as { status: number; bodyText?: string };
|
||||
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
|
||||
} else {
|
||||
setMtxError("failed_to_restart_docker");
|
||||
}
|
||||
} finally {
|
||||
setMtxBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -66,35 +165,44 @@ export default function Settings() {
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{mtxError ? (
|
||||
<div className="rounded-lg border border-rose-900/60 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">
|
||||
{mtxError}
|
||||
</div>
|
||||
) : null}
|
||||
{restartMsg ? (
|
||||
<div className="rounded-lg border border-emerald-900/60 bg-emerald-950/30 px-4 py-3 text-sm text-emerald-200">
|
||||
{restartMsg}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-200">Cameras</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
Đồng bộ paths lên MediaMTX thông qua Control API
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">Đọc và chỉnh trực tiếp từ `mediamtx.yml`</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{(config?.cameras ?? []).map((c) => (
|
||||
{(mtx?.cameras ?? []).map((camera) => (
|
||||
<div
|
||||
key={c.name}
|
||||
key={camera.name}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-zinc-100">{c.name}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{c.rtsp_url}</div>
|
||||
<div className="truncate text-sm text-zinc-100">{camera.name}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{camera.rtsp_url}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteCamera(c.name)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-700 bg-zinc-950/40 text-zinc-200 transition hover:bg-zinc-900"
|
||||
onClick={() => void onDelete(camera.name)}
|
||||
disabled={mtxBusy}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-700 bg-zinc-950/40 text-zinc-200 transition hover:bg-zinc-900 disabled:opacity-60"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{(config?.cameras ?? []).length === 0 ? (
|
||||
{(mtx?.cameras ?? []).length === 0 ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
|
||||
Chưa có camera.
|
||||
</div>
|
||||
@@ -102,29 +210,18 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => void onAdd(e)} className="mt-4 space-y-2">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="cam1"
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">RTSP URL</label>
|
||||
<input
|
||||
value={rtspUrl}
|
||||
onChange={(e) => setRtspUrl(e.target.value)}
|
||||
placeholder="rtsp://user:pass@ip:554/stream"
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">RTSP URL</label>
|
||||
<input
|
||||
value={rtspUrl}
|
||||
onChange={(e) => setRtspUrl(e.target.value)}
|
||||
placeholder="rtsp://user:pass@ip:554/stream"
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canAdd || isLoading}
|
||||
disabled={!canAdd || isLoading || mtxBusy}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-200 transition hover:bg-zinc-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -134,10 +231,20 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-200">Recording Schedule</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">Backend sẽ bật/tắt record mỗi 60 giây</div>
|
||||
<div className="text-xs font-semibold text-zinc-200">Recording & Scheduler</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">Có thể đổi record trong `mediamtx.yml` và scheduler backend</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(mtx?.record_enabled)}
|
||||
onChange={(e) => void onToggleRecord(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-zinc-700 bg-zinc-950"
|
||||
/>
|
||||
MediaMTX record (pathDefaults.record)
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -187,11 +294,19 @@ export default function Settings() {
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRestartDocker()}
|
||||
disabled={mtxBusy}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-200 transition hover:bg-zinc-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Restart MediaMTX Docker
|
||||
</button>
|
||||
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
|
||||
MediaMTX API: <span className="text-zinc-200">{config?.mediamtx_api_url ?? "-"}</span>
|
||||
MediaMTX API: <span className="text-zinc-200">{mtx?.api_url ?? config?.mediamtx_api_url ?? "-"}</span>
|
||||
<br />
|
||||
WebRTC: <span className="text-zinc-200">{config?.mediamtx_webrtc_url ?? "-"}</span>
|
||||
WebRTC: <span className="text-zinc-200">{mtx?.webrtc_url ?? config?.mediamtx_webrtc_url ?? "-"}</span>
|
||||
<br />
|
||||
Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,10 @@ export type Schedule = {
|
||||
export type AppConfig = {
|
||||
mediamtx_api_url: string;
|
||||
mediamtx_webrtc_url: string;
|
||||
mediamtx_api_user?: string | null;
|
||||
mediamtx_api_pass?: string | null;
|
||||
recordings_dir: string;
|
||||
api_port?: number;
|
||||
cameras: Camera[];
|
||||
schedule: Schedule;
|
||||
};
|
||||
@@ -36,3 +39,9 @@ export type MediaMtxPathsListResponse = {
|
||||
pageCount?: number;
|
||||
};
|
||||
|
||||
export type MediaMtxConfigView = {
|
||||
api_url: string;
|
||||
webrtc_url: string;
|
||||
record_enabled: boolean;
|
||||
cameras: Camera[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user