5c2c0f6006
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
157 lines
4.7 KiB
TypeScript
157 lines
4.7 KiB
TypeScript
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;
|
|
/** Swap the outgoing video track on every active peer (e.g. when the XR mirror canvas becomes available). */
|
|
replaceVideoTrack: (newStream: MediaStream) => Promise<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;
|
|
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);
|
|
|
|
// Track the active stream so we can swap it on the fly
|
|
let activeStream: MediaStream = stream;
|
|
|
|
// 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);
|
|
}
|