diff --git a/src/lib/webrtc/broadcaster.ts b/src/lib/webrtc/broadcaster.ts new file mode 100644 index 0000000..fa4b7f6 --- /dev/null +++ b/src/lib/webrtc/broadcaster.ts @@ -0,0 +1,127 @@ +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; +} + +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; + + 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); + + stream.getTracks().forEach((t) => pc.addTrack(t, stream)); + + 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 + stream.getTracks().forEach((t) => { + t.addEventListener('ended', () => stop()); + }); + + 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); + }; + + return { + code, + stop, + 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); +} diff --git a/src/lib/webrtc/signaling.ts b/src/lib/webrtc/signaling.ts new file mode 100644 index 0000000..ab48542 --- /dev/null +++ b/src/lib/webrtc/signaling.ts @@ -0,0 +1,40 @@ +import { supabase } from '@/integrations/supabase/client'; +import type { RealtimeChannel } from '@supabase/supabase-js'; + +export type SignalMessage = + | { type: 'viewer-join'; viewerId: string } + | { type: 'viewer-leave'; viewerId: string } + | { type: 'offer'; viewerId: string; sdp: RTCSessionDescriptionInit } + | { type: 'answer'; viewerId: string; sdp: RTCSessionDescriptionInit } + | { type: 'ice'; viewerId: string; from: 'broadcaster' | 'viewer'; candidate: RTCIceCandidateInit }; + +export function joinSignalingChannel( + code: string, + onMessage: (msg: SignalMessage) => void, +): RealtimeChannel { + const channel = supabase.channel(`share:${code}`, { + config: { broadcast: { self: false, ack: false } }, + }); + + channel + .on('broadcast', { event: 'signal' }, ({ payload }) => onMessage(payload as SignalMessage)) + .subscribe(); + + return channel; +} + +export function sendSignal(channel: RealtimeChannel, msg: SignalMessage) { + return channel.send({ type: 'broadcast', event: 'signal', payload: msg }); +} + +export function generateRoomCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // unambiguous + let out = ''; + for (let i = 0; i < 6; i++) out += chars[Math.floor(Math.random() * chars.length)]; + return out; +} + +export const ICE_SERVERS: RTCIceServer[] = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, +]; diff --git a/src/lib/webrtc/viewer.ts b/src/lib/webrtc/viewer.ts new file mode 100644 index 0000000..fd0d329 --- /dev/null +++ b/src/lib/webrtc/viewer.ts @@ -0,0 +1,89 @@ +import { + joinSignalingChannel, + sendSignal, + ICE_SERVERS, + type SignalMessage, +} from './signaling'; +import type { RealtimeChannel } from '@supabase/supabase-js'; + +export interface ViewerHandle { + stop: () => Promise; +} + +/** + * Connects to a broadcaster's room and renders the incoming stream into the given