export function useStream(mode: "host" | "viewer") { const media = shallowRef(null); const remote = shallowRef(null); const state = ref("offline"); const error = ref(""); const source = ref<"front" | "back" | "screen">("front"); const rearAvailable = ref(false); const screenAvailable = ref(false); const resolution = ref(""); const quality = ref("auto"); let socket: WebSocket | null = null; const peers = new Map(); const pending = new Map(); let retry: ReturnType | undefined; let stats: ReturnType | undefined; let reconnects = 0; let desired = false; let captureVersion = 0; const apiError = useApiError(); const session = useSessionStore(); function send(type: string, target: string, data?: unknown) { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type, target, data })); } function clearPeers() { for (const p of peers.values()) p.close(); peers.clear(); pending.clear(); remote.value = null; resolution.value = ""; } function removePeer(id: string) { peers.get(id)?.close(); peers.delete(id); pending.delete(id); } async function setQuality(value: string) { quality.value = value; for (const peer of peers.values()) for (const sender of peer.getSenders()) { if (sender.track?.kind !== "video") continue; const params = sender.getParameters(); if (!params.encodings?.length) params.encodings = [{}]; params.encodings[0]!.maxBitrate = value === "low" ? 600_000 : value === "high" ? 4_000_000 : 2_000_000; try { await sender.setParameters(params); } catch { /* Browser may not expose encoding controls. */ } } } function peer(id: string) { const p = new RTCPeerConnection({ iceServers: [] }); // LAN-only: no external STUN/TURN services. peers.set(id, p); p.onicecandidate = (e) => { if (e.candidate) send("ice", id, e.candidate.toJSON()); }; p.ontrack = (e) => { remote.value = e.streams[0] ?? new MediaStream([e.track]); state.value = "live"; }; p.onconnectionstatechange = () => { if (p.connectionState === "connected") { if (mode === "viewer") state.value = "live"; void setQuality(quality.value); } if (p.connectionState === "disconnected" && mode === "viewer") state.value = "reconnecting"; if (p.connectionState === "failed") { removePeer(id); if (mode === "viewer") { state.value = "reconnecting"; send("renegotiate", id); } } }; if (mode === "host") media.value ?.getTracks() .forEach((track) => p.addTrack(track, media.value!)); return p; } async function offer(id: string) { removePeer(id); const p = peer(id); await p.setLocalDescription(await p.createOffer()); send("offer", id, p.localDescription); } async function drain(id: string, p: RTCPeerConnection) { for (const ice of pending.get(id) ?? []) await p.addIceCandidate(ice); pending.delete(id); } async function message(v: any) { if (v.type === "error") { desired = false; error.value = apiError({ data: { error: v.error } }); state.value = "offline"; socket?.close(); return; } if (v.type === "welcome") { state.value = mode === "host" ? "live" : v.live ? "connecting" : "waiting"; reconnects = 0; } if (v.type === "host-offline") { clearPeers(); state.value = "waiting"; } if (v.type === "host-online") state.value = "connecting"; if (v.type === "viewer-left") removePeer(v.peer); if ( (v.type === "viewer-joined" || v.type === "renegotiate") && mode === "host" ) await offer(v.peer); if (v.type === "offer" && mode === "viewer") { // ICE messages are serialized behind this handler. const earlyIce = pending.get(v.peer) ?? []; removePeer(v.peer); pending.set(v.peer, earlyIce); const p = peer(v.peer); await p.setRemoteDescription(v.data); await drain(v.peer, p); await p.setLocalDescription(await p.createAnswer()); send("answer", v.peer, p.localDescription); } if (v.type === "answer") { const p = peers.get(v.peer); if (p) { await p.setRemoteDescription(v.data); await drain(v.peer, p); } } if (v.type === "ice") { const p = peers.get(v.peer); if (p?.remoteDescription) await p.addIceCandidate(v.data); else pending.set(v.peer, [...(pending.get(v.peer) ?? []), v.data]); } } function connect() { if (!desired) return; state.value = reconnects ? "reconnecting" : "connecting"; const ws = new WebSocket( `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/signal?mode=${mode}`, ); socket = ws; let queue = Promise.resolve(); ws.onmessage = (e) => { queue = queue .then(() => { if (socket === ws && desired) return message(JSON.parse(e.data)); }) .catch((e) => { error.value = apiError(e); }); }; ws.onclose = async () => { if (socket !== ws) return; socket = null; clearPeers(); if (!desired) return; state.value = "reconnecting"; if (!(await session.refresh())) { desired = false; stop(); await navigateTo("/login"); return; } if (!desired) return; retry = setTimeout(connect, Math.min(1000 * 2 ** reconnects++, 10000)); }; } async function detectSources() { screenAvailable.value = !!navigator.mediaDevices?.getDisplayMedia; try { const devices = await navigator.mediaDevices?.enumerateDevices(); rearAvailable.value = !!devices?.some( (d) => d.kind === "videoinput" && /back|rear|environment/i.test(d.label), ) || (devices?.filter((d) => d.kind === "videoinput").length ?? 0) > 1; } catch { rearAvailable.value = false; } } async function capture(selected = source.value) { const version = ++captureVersion; error.value = ""; if (!window.isSecureContext || !navigator.mediaDevices) { error.value = apiError({ data: { error: "secureContext" } }); return false; } let next: MediaStream | null = null; try { const acquire = () => selected === "screen" ? navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: false, }) : navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: selected === "back" ? { exact: "environment" } : "user", width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30, max: 30 }, }, }); try { next = await acquire(); } catch (e: any) { // Some mobile browsers require releasing the previous camera before opening another lens. if ( e.name !== "NotReadableError" || !media.value || source.value === "screen" || selected === "screen" ) throw e; media.value.getTracks().forEach((t) => t.stop()); try { next = await acquire(); } catch (retryError) { stop(); throw retryError; } } if (version !== captureVersion) { next.getTracks().forEach((t) => t.stop()); return false; } const track = next.getVideoTracks()[0]!; const renegotiate: string[] = []; for (const [id, p] of peers) { const sender = p.getSenders().find((s) => s.track?.kind === "video"); try { await sender?.replaceTrack(track); } catch { renegotiate.push(id); } } const previous = media.value; media.value = next; source.value = selected; for (const id of renegotiate) await offer(id); previous?.getTracks().forEach((t) => t.stop()); track.onended = () => { if (media.value === next) stop(); }; await detectSources(); return true; } catch (e) { next?.getTracks().forEach((t) => t.stop()); error.value = apiError(e); return false; } } async function start() { error.value = ""; if (desired) return; if (mode === "host" && !media.value && !(await capture())) return; desired = true; reconnects = 0; connect(); } function stop() { desired = false; captureVersion++; clearTimeout(retry); const old = socket; socket = null; old?.close(); clearPeers(); media.value?.getTracks().forEach((t) => t.stop()); media.value = null; state.value = "offline"; } onMounted(() => { void detectSources(); stats = setInterval(async () => { if (mode !== "viewer") return; for (const p of peers.values()) { try { (await p.getStats()).forEach((report) => { if ( report.type === "inbound-rtp" && report.kind === "video" && report.frameWidth ) resolution.value = `${report.frameWidth} × ${report.frameHeight}`; }); } catch { /* A closing peer has no stats. */ } } }, 3000); }); onBeforeUnmount(() => { stop(); clearInterval(stats); }); return { media, remote, state, error, source, rearAvailable, screenAvailable, resolution, quality, capture, start, stop, setQuality, }; }