first commit
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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 {
|
||||
}
|
||||
}
|
||||
|
||||
const v = videoRef.current;
|
||||
if (v) {
|
||||
try {
|
||||
v.srcObject = null;
|
||||
v.removeAttribute("src");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionUrl) {
|
||||
try {
|
||||
await fetch(sessionUrl, { method: "DELETE" });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
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(() => "");
|
||||
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) 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user