import { supabase } from '@/integrations/supabase/client'; import { generateRoomCode, joinSignalingChannel, sendSignal, ICE_SERVERS, type SignalMessage, } from './signaling'; import type { RealtimeChannel } from '@supabase/supabase-js'; export interface BroadcasterHandle { code: string; stop: () => Promise; onViewerCountChange: (cb: (n: number) => void) => () => void; /** Swap the outgoing video track on every active peer (e.g. when the XR mirror canvas becomes available). */ replaceVideoTrack: (newStream: MediaStream) => Promise; } const MAX_VIEWERS = 5; /** * Starts broadcasting a MediaStream under a fresh room code. * Returns a handle exposing the code and a stop() to teardown. */ export async function startBroadcast(stream: MediaStream): Promise { const code = generateRoomCode(); // Insert room const { error: insErr } = await supabase.from('share_rooms').insert({ code, is_active: true }); if (insErr) throw new Error(`Falha ao criar sala: ${insErr.message}`); const peers = new Map(); const listeners = new Set<(n: number) => void>(); const notify = () => listeners.forEach((cb) => cb(peers.size)); let channel: RealtimeChannel | null = null; let activeStream: MediaStream = stream; const handleMessage = async (msg: SignalMessage) => { if (msg.type === 'viewer-join') { if (peers.size >= MAX_VIEWERS) return; const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); peers.set(msg.viewerId, pc); activeStream.getTracks().forEach((t) => pc.addTrack(t, activeStream)); pc.onicecandidate = (ev) => { if (ev.candidate && channel) { sendSignal(channel, { type: 'ice', viewerId: msg.viewerId, from: 'broadcaster', candidate: ev.candidate.toJSON(), }); } }; pc.onconnectionstatechange = () => { if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) { peers.delete(msg.viewerId); pc.close(); notify(); updateViewerCount(code, peers.size); } }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); if (channel) { sendSignal(channel, { type: 'offer', viewerId: msg.viewerId, sdp: offer }); } notify(); updateViewerCount(code, peers.size); } else if (msg.type === 'answer') { const pc = peers.get(msg.viewerId); if (pc && pc.signalingState !== 'stable') { await pc.setRemoteDescription(msg.sdp); } } else if (msg.type === 'ice' && msg.from === 'viewer') { const pc = peers.get(msg.viewerId); if (pc) { try { await pc.addIceCandidate(msg.candidate); } catch (e) { console.warn('[broadcaster] addIceCandidate failed', e); } } } else if (msg.type === 'viewer-leave') { const pc = peers.get(msg.viewerId); if (pc) { pc.close(); peers.delete(msg.viewerId); notify(); updateViewerCount(code, peers.size); } } }; channel = joinSignalingChannel(code, handleMessage); // Stop when the underlying stream ends const attachEndedListeners = (s: MediaStream) => { s.getTracks().forEach((t) => { t.addEventListener('ended', () => { if (s === activeStream) stop(); }); }); }; attachEndedListeners(stream); let stopped = false; const stop = async () => { if (stopped) return; stopped = true; peers.forEach((pc) => pc.close()); peers.clear(); notify(); if (channel) await supabase.removeChannel(channel); await supabase.from('share_rooms').update({ is_active: false, viewer_count: 0 }).eq('code', code); }; const replaceVideoTrack = async (newStream: MediaStream) => { const newVideo = newStream.getVideoTracks()[0]; if (!newVideo) return; activeStream = newStream; attachEndedListeners(newStream); for (const pc of peers.values()) { const sender = pc.getSenders().find((s) => s.track && s.track.kind === 'video'); if (sender) { try { await sender.replaceTrack(newVideo); } catch (e) { console.warn('[broadcaster] replaceTrack failed', e); } } } }; return { code, stop, replaceVideoTrack, onViewerCountChange: (cb) => { listeners.add(cb); cb(peers.size); return () => listeners.delete(cb); }, }; } async function updateViewerCount(code: string, n: number) { await supabase.from('share_rooms').update({ viewer_count: n }).eq('code', code); }