Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -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<void>;
|
||||
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<BroadcasterHandle> {
|
||||
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<string, RTCPeerConnection>();
|
||||
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);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to a broadcaster's room and renders the incoming stream into the given <video>.
|
||||
*/
|
||||
export async function joinAsViewer(
|
||||
code: string,
|
||||
videoEl: HTMLVideoElement,
|
||||
onState: (s: 'connecting' | 'waiting' | 'live' | 'error', detail?: string) => void,
|
||||
): Promise<ViewerHandle> {
|
||||
const viewerId = crypto.randomUUID();
|
||||
let pc: RTCPeerConnection | null = null;
|
||||
let channel: RealtimeChannel | null = null;
|
||||
|
||||
onState('connecting');
|
||||
|
||||
const handleMessage = async (msg: SignalMessage) => {
|
||||
if (msg.type === 'offer' && msg.viewerId === viewerId) {
|
||||
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||
|
||||
pc.ontrack = (ev) => {
|
||||
videoEl.srcObject = ev.streams[0];
|
||||
videoEl.play().catch(() => {/* autoplay may need user gesture */});
|
||||
onState('live');
|
||||
};
|
||||
|
||||
pc.onicecandidate = (ev) => {
|
||||
if (ev.candidate && channel) {
|
||||
sendSignal(channel, {
|
||||
type: 'ice',
|
||||
viewerId,
|
||||
from: 'viewer',
|
||||
candidate: ev.candidate.toJSON(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc && ['failed', 'disconnected'].includes(pc.connectionState)) {
|
||||
onState('error', 'Conexão perdida');
|
||||
}
|
||||
};
|
||||
|
||||
await pc.setRemoteDescription(msg.sdp);
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
if (channel) sendSignal(channel, { type: 'answer', viewerId, sdp: answer });
|
||||
} else if (msg.type === 'ice' && msg.viewerId === viewerId && msg.from === 'broadcaster') {
|
||||
if (pc) {
|
||||
try {
|
||||
await pc.addIceCandidate(msg.candidate);
|
||||
} catch (e) {
|
||||
console.warn('[viewer] addIceCandidate failed', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
channel = joinSignalingChannel(code, handleMessage);
|
||||
|
||||
// Wait a tick for subscribe, then announce join
|
||||
setTimeout(() => {
|
||||
if (channel) {
|
||||
sendSignal(channel, { type: 'viewer-join', viewerId });
|
||||
onState('waiting');
|
||||
}
|
||||
}, 600);
|
||||
|
||||
const stop = async () => {
|
||||
if (channel) {
|
||||
try { sendSignal(channel, { type: 'viewer-leave', viewerId }); } catch {}
|
||||
const { supabase } = await import('@/integrations/supabase/client');
|
||||
await supabase.removeChannel(channel);
|
||||
}
|
||||
if (pc) pc.close();
|
||||
};
|
||||
|
||||
return { stop };
|
||||
}
|
||||
Reference in New Issue
Block a user