192 lines
5.0 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
|