diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx index d0d7a66..bc483e9 100644 --- a/src/components/ShareButton.tsx +++ b/src/components/ShareButton.tsx @@ -30,6 +30,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV // Prefer the XR broadcast mirror (opaque) if active — required during AR passthrough. let stream: MediaStream | null = null; const mirror = getBroadcastSource(); + let frameSource: HTMLCanvasElement | null = mirror; if (mirror && typeof mirror.captureStream === 'function') { stream = mirror.captureStream(24); } @@ -38,6 +39,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV if (!stream || stream.getVideoTracks().length === 0) { const canvas = document.querySelector('canvas') as HTMLCanvasElement | null; if (canvas && typeof canvas.captureStream === 'function') { + frameSource = canvas; stream = canvas.captureStream(30); } } @@ -51,7 +53,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV } streamRef.current = stream; - const h = await startBroadcast(stream); + const h = await startBroadcast(stream, { frameSource }); setHandle(h); onHandleChange?.(h); h.onViewerCountChange((n) => { setViewerCount(n); onViewerCountChange?.(n); }); @@ -96,6 +98,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV useEffect(() => { if (!handle) return; const swapTo = (canvas: HTMLCanvasElement | null) => { + handle.setFrameSource(canvas); if (!canvas || typeof canvas.captureStream !== 'function') return; const newStream = canvas.captureStream(24); const oldStream = streamRef.current; diff --git a/src/lib/webrtc/broadcaster.ts b/src/lib/webrtc/broadcaster.ts index 10d5203..497f486 100644 --- a/src/lib/webrtc/broadcaster.ts +++ b/src/lib/webrtc/broadcaster.ts @@ -3,6 +3,7 @@ import { generateRoomCode, joinSignalingChannel, sendSignal, + waitForSignalingReady, ICE_SERVERS, type SignalMessage, } from './signaling'; @@ -14,6 +15,8 @@ export interface BroadcasterHandle { 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; + /** Low-FPS backup source used when Quest/browser captureStream produces black frames. */ + setFrameSource: (canvas: HTMLCanvasElement | null) => void; } const MAX_VIEWERS = 5; @@ -22,7 +25,10 @@ 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 { +export async function startBroadcast( + stream: MediaStream, + options: { frameSource?: HTMLCanvasElement | null } = {}, +): Promise { const code = generateRoomCode(); // Insert room @@ -35,6 +41,8 @@ export async function startBroadcast(stream: MediaStream): Promise { if (msg.type === 'viewer-join') { @@ -43,10 +51,11 @@ export async function startBroadcast(stream: MediaStream): Promise pc.addTrack(t, activeStream)); + if (activeStream.getTracks().length === 0) pc.createDataChannel('fallback-frames'); pc.onicecandidate = (ev) => { if (ev.candidate && channel) { - sendSignal(channel, { + void sendSignal(channel, { type: 'ice', viewerId: msg.viewerId, from: 'broadcaster', @@ -67,7 +76,7 @@ export async function startBroadcast(stream: MediaStream): Promise { + if (!channel || !frameSource || !frameCtx || peers.size === 0 || frameBusy) return; + frameBusy = true; + try { + frameCtx.fillStyle = '#1a1a1a'; + frameCtx.fillRect(0, 0, frameCanvas.width, frameCanvas.height); + frameCtx.drawImage(frameSource, 0, 0, frameCanvas.width, frameCanvas.height); + const dataUrl = frameCanvas.toDataURL('image/jpeg', 0.55); + void sendSignal(channel, { type: 'frame', dataUrl, ts: Date.now() }).finally(() => { frameBusy = false; }); + } catch (e) { + frameBusy = false; + console.warn('[broadcaster] frame fallback failed', e); + } + }, 500); // Stop when the underlying stream ends const attachEndedListeners = (s: MediaStream) => { @@ -114,6 +143,7 @@ export async function startBroadcast(stream: MediaStream): Promise pc.close()); peers.clear(); + window.clearInterval(frameTimer); notify(); if (channel) await supabase.removeChannel(channel); await supabase.from('share_rooms').update({ is_active: false, viewer_count: 0 }).eq('code', code); @@ -140,6 +170,7 @@ export async function startBroadcast(stream: MediaStream): Promise { frameSource = canvas; }, onViewerCountChange: (cb) => { listeners.add(cb); cb(peers.size); diff --git a/src/lib/webrtc/signaling.ts b/src/lib/webrtc/signaling.ts index ab48542..65dd22c 100644 --- a/src/lib/webrtc/signaling.ts +++ b/src/lib/webrtc/signaling.ts @@ -6,25 +6,58 @@ export type SignalMessage = | { 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 }; + | { type: 'ice'; viewerId: string; from: 'broadcaster' | 'viewer'; candidate: RTCIceCandidateInit } + | { type: 'frame'; dataUrl: string; ts: number }; + +const readyChannels = new WeakMap>(); export function joinSignalingChannel( code: string, onMessage: (msg: SignalMessage) => void, ): RealtimeChannel { + let resolveReady!: () => void; + let rejectReady!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const channel = supabase.channel(`share:${code}`, { config: { broadcast: { self: false, ack: false } }, }); + readyChannels.set(channel, ready); + channel .on('broadcast', { event: 'signal' }, ({ payload }) => onMessage(payload as SignalMessage)) - .subscribe(); + .subscribe((status, err) => { + if (status === 'SUBSCRIBED') resolveReady(); + if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') { + rejectReady(err instanceof Error ? err : new Error(`Canal de sinalização: ${status}`)); + } + }); return channel; } -export function sendSignal(channel: RealtimeChannel, msg: SignalMessage) { - return channel.send({ type: 'broadcast', event: 'signal', payload: msg }); +export async function waitForSignalingReady(channel: RealtimeChannel, timeoutMs = 5000) { + const ready = readyChannels.get(channel); + if (!ready) return; + await Promise.race([ + ready, + new Promise((_, reject) => { + window.setTimeout(() => reject(new Error('Tempo esgotado ao conectar sinalização')), timeoutMs); + }), + ]); +} + +export async function sendSignal(channel: RealtimeChannel, msg: SignalMessage) { + await waitForSignalingReady(channel); + const response = await channel.send({ type: 'broadcast', event: 'signal', payload: msg }); + if (response !== 'ok') { + const restResponse = await channel.httpSend('signal', msg); + if (restResponse.success === false) throw new Error(restResponse.error); + } } export function generateRoomCode(): string { diff --git a/src/lib/webrtc/viewer.ts b/src/lib/webrtc/viewer.ts index fd0d329..87ab1b3 100644 --- a/src/lib/webrtc/viewer.ts +++ b/src/lib/webrtc/viewer.ts @@ -1,6 +1,7 @@ import { joinSignalingChannel, sendSignal, + waitForSignalingReady, ICE_SERVERS, type SignalMessage, } from './signaling'; @@ -17,6 +18,7 @@ export async function joinAsViewer( code: string, videoEl: HTMLVideoElement, onState: (s: 'connecting' | 'waiting' | 'live' | 'error', detail?: string) => void, + onFallbackFrame?: (dataUrl: string) => void, ): Promise { const viewerId = crypto.randomUUID(); let pc: RTCPeerConnection | null = null; @@ -25,18 +27,24 @@ export async function joinAsViewer( onState('connecting'); const handleMessage = async (msg: SignalMessage) => { + if (msg.type === 'frame') { + onFallbackFrame?.(msg.dataUrl); + onState('live'); + return; + } + if (msg.type === 'offer' && msg.viewerId === viewerId) { pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); pc.ontrack = (ev) => { - videoEl.srcObject = ev.streams[0]; + videoEl.srcObject = ev.streams[0] ?? new MediaStream([ev.track]); videoEl.play().catch(() => {/* autoplay may need user gesture */}); onState('live'); }; pc.onicecandidate = (ev) => { if (ev.candidate && channel) { - sendSignal(channel, { + void sendSignal(channel, { type: 'ice', viewerId, from: 'viewer', @@ -54,7 +62,7 @@ export async function joinAsViewer( await pc.setRemoteDescription(msg.sdp); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); - if (channel) sendSignal(channel, { type: 'answer', viewerId, sdp: answer }); + if (channel) await sendSignal(channel, { type: 'answer', viewerId, sdp: answer }); } else if (msg.type === 'ice' && msg.viewerId === viewerId && msg.from === 'broadcaster') { if (pc) { try { @@ -67,18 +75,14 @@ export async function joinAsViewer( }; channel = joinSignalingChannel(code, handleMessage); + await waitForSignalingReady(channel); - // Wait a tick for subscribe, then announce join - setTimeout(() => { - if (channel) { - sendSignal(channel, { type: 'viewer-join', viewerId }); - onState('waiting'); - } - }, 600); + await sendSignal(channel, { type: 'viewer-join', viewerId }); + onState('waiting'); const stop = async () => { if (channel) { - try { sendSignal(channel, { type: 'viewer-leave', viewerId }); } catch {} + try { await sendSignal(channel, { type: 'viewer-leave', viewerId }); } catch {} const { supabase } = await import('@/integrations/supabase/client'); await supabase.removeChannel(channel); } diff --git a/src/pages/Watch.tsx b/src/pages/Watch.tsx index e9897e4..f969c5f 100644 --- a/src/pages/Watch.tsx +++ b/src/pages/Watch.tsx @@ -14,6 +14,7 @@ export default function Watch() { const [state, setState] = useState('checking'); const [error, setError] = useState(''); const [fullscreen, setFullscreen] = useState(false); + const [fallbackFrame, setFallbackFrame] = useState(''); useEffect(() => { let cancelled = false; @@ -39,10 +40,15 @@ export default function Watch() { if (!videoRef.current) return; try { - const handle = await joinAsViewer(code.toUpperCase(), videoRef.current, (s, detail) => { - setState(s as ConnState); - if (detail) setError(detail); - }); + const handle = await joinAsViewer( + code.toUpperCase(), + videoRef.current, + (s, detail) => { + setState(s as ConnState); + if (detail) setError(detail); + }, + setFallbackFrame, + ); handleRef.current = handle; } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -100,6 +106,14 @@ export default function Watch() { className="max-w-full max-h-full object-contain" /> + {fallbackFrame && ( + Transmissão ao vivo da sessão XR + )} + {(state === 'checking' || state === 'connecting' || state === 'waiting') && (