From 06be3502f90a66ceab3555bfe4e0d513ff8c1d3b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:02:02 +0000 Subject: [PATCH 1/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/lib/webrtc/signaling.ts | 38 ++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/lib/webrtc/signaling.ts b/src/lib/webrtc/signaling.ts index ab48542..f580bc4 100644 --- a/src/lib/webrtc/signaling.ts +++ b/src/lib/webrtc/signaling.ts @@ -8,23 +8,55 @@ export type SignalMessage = | { type: 'answer'; viewerId: string; sdp: RTCSessionDescriptionInit } | { type: 'ice'; viewerId: string; from: 'broadcaster' | 'viewer'; candidate: RTCIceCandidateInit }; +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) throw new Error(restResponse.error); + } } export function generateRoomCode(): string { From a516e1bf693d4fa6f757dceae7ecbbca246f4d72 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:02:14 +0000 Subject: [PATCH 2/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/lib/webrtc/signaling.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/webrtc/signaling.ts b/src/lib/webrtc/signaling.ts index f580bc4..fb07734 100644 --- a/src/lib/webrtc/signaling.ts +++ b/src/lib/webrtc/signaling.ts @@ -55,7 +55,7 @@ export async function sendSignal(channel: RealtimeChannel, msg: SignalMessage) { const response = await channel.send({ type: 'broadcast', event: 'signal', payload: msg }); if (response !== 'ok') { const restResponse = await channel.httpSend('signal', msg); - if (!restResponse.success) throw new Error(restResponse.error); + if (restResponse.success === false) throw new Error(restResponse.error); } } From 880d09231d22feeecf631ca133ea1f926e5c828c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:04:03 +0000 Subject: [PATCH 3/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/lib/webrtc/broadcaster.ts | 36 ++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/lib/webrtc/broadcaster.ts b/src/lib/webrtc/broadcaster.ts index 10d5203..96651a9 100644 --- a/src/lib/webrtc/broadcaster.ts +++ b/src/lib/webrtc/broadcaster.ts @@ -14,6 +14,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 +24,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 +40,8 @@ export async function startBroadcast(stream: MediaStream): Promise { if (msg.type === 'viewer-join') { @@ -43,10 +50,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 +75,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 +142,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 +169,7 @@ export async function startBroadcast(stream: MediaStream): Promise { frameSource = canvas; }, onViewerCountChange: (cb) => { listeners.add(cb); cb(peers.size); From 10a0c83584082e5931d543bafac180c717906787 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:04:23 +0000 Subject: [PATCH 4/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/lib/webrtc/broadcaster.ts | 1 + src/lib/webrtc/signaling.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/webrtc/broadcaster.ts b/src/lib/webrtc/broadcaster.ts index 96651a9..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'; diff --git a/src/lib/webrtc/signaling.ts b/src/lib/webrtc/signaling.ts index fb07734..65dd22c 100644 --- a/src/lib/webrtc/signaling.ts +++ b/src/lib/webrtc/signaling.ts @@ -6,7 +6,8 @@ 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>(); From 63852195fc69f4c22575c7a1a97990d49f4a20ab Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:04:44 +0000 Subject: [PATCH 5/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/lib/webrtc/viewer.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) 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); } From b60ee133ac5c74908020e2372ffa88a60e0dc83b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:05:04 +0000 Subject: [PATCH 6/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/ShareButton.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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; From 72654c84726ee766b40c766149aa7eeef48dfdbe Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:05:23 +0000 Subject: [PATCH 7/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/pages/Watch.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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') && (