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((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(null); const pcRef = useRef(null); const sessionUrlRef = useRef(null); const abortRef = useRef(null); const [status, setStatus] = useState("idle"); const [error, setError] = useState(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, }; }