Files
IPCam_OrangePi_Dashboard/src/hooks/useWhepPlayer.ts
T
2026-04-28 17:24:26 +07:00

192 lines
5.0 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type PlayerStatus = "idle" | "connecting" | "playing" | "error";
function withNoTrailingSlash(url: string) {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
async function waitForIceGatheringComplete(pc: RTCPeerConnection) {
if (pc.iceGatheringState === "complete") return;
await new Promise<void>((resolve) => {
const onState = () => {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", onState);
resolve();
}
};
pc.addEventListener("icegatheringstatechange", onState);
});
}
export function useWhepPlayer({
enabled,
webrtcBaseUrl,
streamName,
}: {
enabled: boolean;
webrtcBaseUrl: string;
streamName: string;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const pcRef = useRef<RTCPeerConnection | null>(null);
const sessionUrlRef = useRef<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [status, setStatus] = useState<PlayerStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [restartNonce, setRestartNonce] = useState(0);
const whepUrl = useMemo(() => {
return `${withNoTrailingSlash(webrtcBaseUrl)}/${encodeURIComponent(streamName)}/whep`;
}, [webrtcBaseUrl, streamName]);
const stop = useCallback(async () => {
abortRef.current?.abort();
abortRef.current = null;
const sessionUrl = sessionUrlRef.current;
sessionUrlRef.current = null;
const pc = pcRef.current;
pcRef.current = null;
if (pc) {
try {
pc.ontrack = null;
pc.onconnectionstatechange = null;
pc.oniceconnectionstatechange = null;
pc.close();
} catch {
// Ignore teardown errors when peer connection is already closed.
}
}
const v = videoRef.current;
if (v) {
try {
v.srcObject = null;
v.removeAttribute("src");
} catch {
// Ignore cleanup errors on detached video element.
}
}
if (sessionUrl) {
try {
await fetch(sessionUrl, { method: "DELETE" });
} catch {
// Ignore best-effort WHEP session cleanup failures.
}
}
}, []);
const restart = useCallback(() => {
setRestartNonce((n) => n + 1);
}, []);
useEffect(() => {
let disposed = false;
const run = async () => {
await stop();
if (!enabled) {
setStatus("idle");
setError(null);
return;
}
setStatus("connecting");
setError(null);
const abort = new AbortController();
abortRef.current = abort;
try {
const videoEl = videoRef.current;
if (!videoEl) throw new Error("no_video");
const pc = new RTCPeerConnection();
pcRef.current = pc;
pc.addTransceiver("video", { direction: "recvonly" });
pc.addTransceiver("audio", { direction: "recvonly" });
pc.ontrack = (ev) => {
const stream = ev.streams?.[0];
if (!stream) return;
if (videoEl.srcObject !== stream) {
videoEl.srcObject = stream;
void videoEl.play().catch(() => undefined);
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === "failed") {
restart();
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
const sdpOffer = pc.localDescription?.sdp;
if (!sdpOffer) throw new Error("no_offer");
const res = await fetch(whepUrl, {
method: "POST",
headers: {
"Content-Type": "application/sdp",
Accept: "application/sdp",
},
body: sdpOffer,
signal: abort.signal,
});
if (!res.ok) {
const t = await res.text().catch(() => "");
if (res.status === 405) {
throw new Error(
"whep_http_405: endpoint WebRTC khong cho phep POST. Kiem tra mediamtx_webrtc_url (host/port) va nginx proxy."
);
}
throw new Error(`whep_http_${res.status}${t ? `: ${t}` : ""}`);
}
const answerSdp = await res.text();
const location = res.headers.get("location") || res.headers.get("Location");
if (location) {
try {
sessionUrlRef.current = new URL(location, whepUrl).toString();
} catch {
sessionUrlRef.current = location;
}
}
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
if (!disposed) setStatus("playing");
} catch (e) {
if (!disposed) {
setStatus("error");
setError(e instanceof Error ? e.message : "unknown_error");
}
}
};
run();
return () => {
disposed = true;
void stop();
};
}, [enabled, restartNonce, stop, whepUrl, restart]);
return {
videoRef,
status,
error,
restart,
stop,
};
}