first commit

This commit is contained in:
2026-04-26 21:27:00 +07:00
commit 3ce6f0510b
48 changed files with 9700 additions and 0 deletions
+177
View File
@@ -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,
};
}