diff --git a/.ai/commit-checkpoints.json b/.ai/commit-checkpoints.json index c7f2c90c..70794d20 100644 --- a/.ai/commit-checkpoints.json +++ b/.ai/commit-checkpoints.json @@ -1,7 +1,7 @@ { "last_commit": { - "timestamp": "2025-12-21T23:20:32.869030+00:00", - "session_id": "019b4336-8118-7961-95cb-f63fd5f8c638", - "last_entry_timestamp": "2025-12-21T23:20:28.851Z" + "timestamp": "2025-12-22T02:15:31.164777+00:00", + "session_id": "019b43d6-7d65-7750-9f2a-d85aa2642074", + "last_entry_timestamp": "2025-12-22T02:15:26.603Z" } } \ No newline at end of file diff --git a/packages/web/src/components/WebRTCPlayer.tsx b/packages/web/src/components/WebRTCPlayer.tsx new file mode 100644 index 00000000..f7fc1703 --- /dev/null +++ b/packages/web/src/components/WebRTCPlayer.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react" + +type WebRTCPlayerProps = { + src: string + autoPlay?: boolean + muted?: boolean + onReady?: () => void +} + +export function WebRTCPlayer({ + src, + autoPlay = true, + muted = false, + onReady, +}: WebRTCPlayerProps) { + const videoRef = useRef(null) + const [error, setError] = useState(null) + const readyRef = useRef(false) + + useEffect(() => { + const video = videoRef.current + if (!video || !src) return + + let pc: RTCPeerConnection | null = new RTCPeerConnection() + const abortController = new AbortController() + readyRef.current = false + + const markReady = () => { + if (readyRef.current) return + readyRef.current = true + onReady?.() + } + + const cleanup = () => { + abortController.abort() + if (pc) { + pc.ontrack = null + pc.onconnectionstatechange = null + pc.oniceconnectionstatechange = null + pc.close() + } + pc = null + if (video.srcObject) { + const tracks = (video.srcObject as MediaStream).getTracks() + tracks.forEach((track) => track.stop()) + video.srcObject = null + } + } + + const waitForIceGathering = () => + new Promise((resolve) => { + if (!pc || pc.iceGatheringState === "complete") { + resolve() + return + } + const onStateChange = () => { + if (!pc) return + if (pc.iceGatheringState === "complete") { + pc.removeEventListener("icegatheringstatechange", onStateChange) + resolve() + } + } + pc.addEventListener("icegatheringstatechange", onStateChange) + }) + + const start = async () => { + try { + if (!pc) return + setError(null) + + pc.addTransceiver("video", { direction: "recvonly" }) + pc.addTransceiver("audio", { direction: "recvonly" }) + + pc.ontrack = (event) => { + const [stream] = event.streams + if (stream && video.srcObject !== stream) { + video.srcObject = stream + video.muted = muted + if (autoPlay) { + video.play().catch(() => {}) + } + markReady() + } + } + + pc.onconnectionstatechange = () => { + if (pc?.connectionState === "connected") { + markReady() + } + } + + const offer = await pc.createOffer() + await pc.setLocalDescription(offer) + await waitForIceGathering() + + const localSdp = pc.localDescription?.sdp + if (!localSdp) { + throw new Error("Missing local SDP") + } + + const response = await fetch(src, { + method: "POST", + headers: { + "content-type": "application/sdp", + }, + body: localSdp, + signal: abortController.signal, + }) + + if (!response.ok) { + throw new Error(`WebRTC request failed (${response.status})`) + } + + const answerSdp = await response.text() + if (!answerSdp) { + throw new Error("Empty WebRTC answer") + } + + await pc.setRemoteDescription({ type: "answer", sdp: answerSdp }) + } catch (err) { + if (!abortController.signal.aborted) { + const message = err instanceof Error ? err.message : "WebRTC failed" + setError(message) + } + } + } + + start() + + return cleanup + }, [autoPlay, muted, onReady, src]) + + if (error) { + return ( +
+

{error}

+
+ ) + } + + return ( +