Adicionou fallback por frames ao link

X-Lovable-Edit-ID: edt-dd8ba24c-d873-41c7-9943-075852e3f66a
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-14 19:06:11 +00:00
5 changed files with 108 additions and 23 deletions
+4 -1
View File
@@ -30,6 +30,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV
// Prefer the XR broadcast mirror (opaque) if active — required during AR passthrough. // Prefer the XR broadcast mirror (opaque) if active — required during AR passthrough.
let stream: MediaStream | null = null; let stream: MediaStream | null = null;
const mirror = getBroadcastSource(); const mirror = getBroadcastSource();
let frameSource: HTMLCanvasElement | null = mirror;
if (mirror && typeof mirror.captureStream === 'function') { if (mirror && typeof mirror.captureStream === 'function') {
stream = mirror.captureStream(24); stream = mirror.captureStream(24);
} }
@@ -38,6 +39,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV
if (!stream || stream.getVideoTracks().length === 0) { if (!stream || stream.getVideoTracks().length === 0) {
const canvas = document.querySelector('canvas') as HTMLCanvasElement | null; const canvas = document.querySelector('canvas') as HTMLCanvasElement | null;
if (canvas && typeof canvas.captureStream === 'function') { if (canvas && typeof canvas.captureStream === 'function') {
frameSource = canvas;
stream = canvas.captureStream(30); stream = canvas.captureStream(30);
} }
} }
@@ -51,7 +53,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV
} }
streamRef.current = stream; streamRef.current = stream;
const h = await startBroadcast(stream); const h = await startBroadcast(stream, { frameSource });
setHandle(h); setHandle(h);
onHandleChange?.(h); onHandleChange?.(h);
h.onViewerCountChange((n) => { setViewerCount(n); onViewerCountChange?.(n); }); h.onViewerCountChange((n) => { setViewerCount(n); onViewerCountChange?.(n); });
@@ -96,6 +98,7 @@ export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onV
useEffect(() => { useEffect(() => {
if (!handle) return; if (!handle) return;
const swapTo = (canvas: HTMLCanvasElement | null) => { const swapTo = (canvas: HTMLCanvasElement | null) => {
handle.setFrameSource(canvas);
if (!canvas || typeof canvas.captureStream !== 'function') return; if (!canvas || typeof canvas.captureStream !== 'function') return;
const newStream = canvas.captureStream(24); const newStream = canvas.captureStream(24);
const oldStream = streamRef.current; const oldStream = streamRef.current;
+34 -3
View File
@@ -3,6 +3,7 @@ import {
generateRoomCode, generateRoomCode,
joinSignalingChannel, joinSignalingChannel,
sendSignal, sendSignal,
waitForSignalingReady,
ICE_SERVERS, ICE_SERVERS,
type SignalMessage, type SignalMessage,
} from './signaling'; } from './signaling';
@@ -14,6 +15,8 @@ export interface BroadcasterHandle {
onViewerCountChange: (cb: (n: number) => void) => () => 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). */ /** Swap the outgoing video track on every active peer (e.g. when the XR mirror canvas becomes available). */
replaceVideoTrack: (newStream: MediaStream) => Promise<void>; replaceVideoTrack: (newStream: MediaStream) => Promise<void>;
/** Low-FPS backup source used when Quest/browser captureStream produces black frames. */
setFrameSource: (canvas: HTMLCanvasElement | null) => void;
} }
const MAX_VIEWERS = 5; const MAX_VIEWERS = 5;
@@ -22,7 +25,10 @@ const MAX_VIEWERS = 5;
* Starts broadcasting a MediaStream under a fresh room code. * Starts broadcasting a MediaStream under a fresh room code.
* Returns a handle exposing the code and a stop() to teardown. * Returns a handle exposing the code and a stop() to teardown.
*/ */
export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHandle> { export async function startBroadcast(
stream: MediaStream,
options: { frameSource?: HTMLCanvasElement | null } = {},
): Promise<BroadcasterHandle> {
const code = generateRoomCode(); const code = generateRoomCode();
// Insert room // Insert room
@@ -35,6 +41,8 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
let channel: RealtimeChannel | null = null; let channel: RealtimeChannel | null = null;
let activeStream: MediaStream = stream; let activeStream: MediaStream = stream;
let frameSource: HTMLCanvasElement | null = options.frameSource ?? null;
let frameBusy = false;
const handleMessage = async (msg: SignalMessage) => { const handleMessage = async (msg: SignalMessage) => {
if (msg.type === 'viewer-join') { if (msg.type === 'viewer-join') {
@@ -43,10 +51,11 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
peers.set(msg.viewerId, pc); peers.set(msg.viewerId, pc);
activeStream.getTracks().forEach((t) => pc.addTrack(t, activeStream)); activeStream.getTracks().forEach((t) => pc.addTrack(t, activeStream));
if (activeStream.getTracks().length === 0) pc.createDataChannel('fallback-frames');
pc.onicecandidate = (ev) => { pc.onicecandidate = (ev) => {
if (ev.candidate && channel) { if (ev.candidate && channel) {
sendSignal(channel, { void sendSignal(channel, {
type: 'ice', type: 'ice',
viewerId: msg.viewerId, viewerId: msg.viewerId,
from: 'broadcaster', from: 'broadcaster',
@@ -67,7 +76,7 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
const offer = await pc.createOffer(); const offer = await pc.createOffer();
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer);
if (channel) { if (channel) {
sendSignal(channel, { type: 'offer', viewerId: msg.viewerId, sdp: offer }); await sendSignal(channel, { type: 'offer', viewerId: msg.viewerId, sdp: offer });
} }
notify(); notify();
updateViewerCount(code, peers.size); updateViewerCount(code, peers.size);
@@ -97,6 +106,26 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
}; };
channel = joinSignalingChannel(code, handleMessage); channel = joinSignalingChannel(code, handleMessage);
await waitForSignalingReady(channel);
const frameCanvas = document.createElement('canvas');
frameCanvas.width = 640;
frameCanvas.height = 360;
const frameCtx = frameCanvas.getContext('2d', { alpha: false });
const frameTimer = window.setInterval(() => {
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 // Stop when the underlying stream ends
const attachEndedListeners = (s: MediaStream) => { const attachEndedListeners = (s: MediaStream) => {
@@ -114,6 +143,7 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
stopped = true; stopped = true;
peers.forEach((pc) => pc.close()); peers.forEach((pc) => pc.close());
peers.clear(); peers.clear();
window.clearInterval(frameTimer);
notify(); notify();
if (channel) await supabase.removeChannel(channel); if (channel) await supabase.removeChannel(channel);
await supabase.from('share_rooms').update({ is_active: false, viewer_count: 0 }).eq('code', code); 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<BroadcasterHa
code, code,
stop, stop,
replaceVideoTrack, replaceVideoTrack,
setFrameSource: (canvas) => { frameSource = canvas; },
onViewerCountChange: (cb) => { onViewerCountChange: (cb) => {
listeners.add(cb); listeners.add(cb);
cb(peers.size); cb(peers.size);
+37 -4
View File
@@ -6,25 +6,58 @@ export type SignalMessage =
| { type: 'viewer-leave'; viewerId: string } | { type: 'viewer-leave'; viewerId: string }
| { type: 'offer'; viewerId: string; sdp: RTCSessionDescriptionInit } | { type: 'offer'; viewerId: string; sdp: RTCSessionDescriptionInit }
| { type: 'answer'; 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<RealtimeChannel, Promise<void>>();
export function joinSignalingChannel( export function joinSignalingChannel(
code: string, code: string,
onMessage: (msg: SignalMessage) => void, onMessage: (msg: SignalMessage) => void,
): RealtimeChannel { ): RealtimeChannel {
let resolveReady!: () => void;
let rejectReady!: (error: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const channel = supabase.channel(`share:${code}`, { const channel = supabase.channel(`share:${code}`, {
config: { broadcast: { self: false, ack: false } }, config: { broadcast: { self: false, ack: false } },
}); });
readyChannels.set(channel, ready);
channel channel
.on('broadcast', { event: 'signal' }, ({ payload }) => onMessage(payload as SignalMessage)) .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; return channel;
} }
export function sendSignal(channel: RealtimeChannel, msg: SignalMessage) { export async function waitForSignalingReady(channel: RealtimeChannel, timeoutMs = 5000) {
return channel.send({ type: 'broadcast', event: 'signal', payload: msg }); const ready = readyChannels.get(channel);
if (!ready) return;
await Promise.race([
ready,
new Promise<void>((_, 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 { export function generateRoomCode(): string {
+15 -11
View File
@@ -1,6 +1,7 @@
import { import {
joinSignalingChannel, joinSignalingChannel,
sendSignal, sendSignal,
waitForSignalingReady,
ICE_SERVERS, ICE_SERVERS,
type SignalMessage, type SignalMessage,
} from './signaling'; } from './signaling';
@@ -17,6 +18,7 @@ export async function joinAsViewer(
code: string, code: string,
videoEl: HTMLVideoElement, videoEl: HTMLVideoElement,
onState: (s: 'connecting' | 'waiting' | 'live' | 'error', detail?: string) => void, onState: (s: 'connecting' | 'waiting' | 'live' | 'error', detail?: string) => void,
onFallbackFrame?: (dataUrl: string) => void,
): Promise<ViewerHandle> { ): Promise<ViewerHandle> {
const viewerId = crypto.randomUUID(); const viewerId = crypto.randomUUID();
let pc: RTCPeerConnection | null = null; let pc: RTCPeerConnection | null = null;
@@ -25,18 +27,24 @@ export async function joinAsViewer(
onState('connecting'); onState('connecting');
const handleMessage = async (msg: SignalMessage) => { const handleMessage = async (msg: SignalMessage) => {
if (msg.type === 'frame') {
onFallbackFrame?.(msg.dataUrl);
onState('live');
return;
}
if (msg.type === 'offer' && msg.viewerId === viewerId) { if (msg.type === 'offer' && msg.viewerId === viewerId) {
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
pc.ontrack = (ev) => { 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 */}); videoEl.play().catch(() => {/* autoplay may need user gesture */});
onState('live'); onState('live');
}; };
pc.onicecandidate = (ev) => { pc.onicecandidate = (ev) => {
if (ev.candidate && channel) { if (ev.candidate && channel) {
sendSignal(channel, { void sendSignal(channel, {
type: 'ice', type: 'ice',
viewerId, viewerId,
from: 'viewer', from: 'viewer',
@@ -54,7 +62,7 @@ export async function joinAsViewer(
await pc.setRemoteDescription(msg.sdp); await pc.setRemoteDescription(msg.sdp);
const answer = await pc.createAnswer(); const answer = await pc.createAnswer();
await pc.setLocalDescription(answer); 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') { } else if (msg.type === 'ice' && msg.viewerId === viewerId && msg.from === 'broadcaster') {
if (pc) { if (pc) {
try { try {
@@ -67,18 +75,14 @@ export async function joinAsViewer(
}; };
channel = joinSignalingChannel(code, handleMessage); channel = joinSignalingChannel(code, handleMessage);
await waitForSignalingReady(channel);
// Wait a tick for subscribe, then announce join await sendSignal(channel, { type: 'viewer-join', viewerId });
setTimeout(() => { onState('waiting');
if (channel) {
sendSignal(channel, { type: 'viewer-join', viewerId });
onState('waiting');
}
}, 600);
const stop = async () => { const stop = async () => {
if (channel) { 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'); const { supabase } = await import('@/integrations/supabase/client');
await supabase.removeChannel(channel); await supabase.removeChannel(channel);
} }
+18 -4
View File
@@ -14,6 +14,7 @@ export default function Watch() {
const [state, setState] = useState<ConnState>('checking'); const [state, setState] = useState<ConnState>('checking');
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const [fallbackFrame, setFallbackFrame] = useState<string>('');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -39,10 +40,15 @@ export default function Watch() {
if (!videoRef.current) return; if (!videoRef.current) return;
try { try {
const handle = await joinAsViewer(code.toUpperCase(), videoRef.current, (s, detail) => { const handle = await joinAsViewer(
setState(s as ConnState); code.toUpperCase(),
if (detail) setError(detail); videoRef.current,
}); (s, detail) => {
setState(s as ConnState);
if (detail) setError(detail);
},
setFallbackFrame,
);
handleRef.current = handle; handleRef.current = handle;
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(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" className="max-w-full max-h-full object-contain"
/> />
{fallbackFrame && (
<img
src={fallbackFrame}
alt="Transmissão ao vivo da sessão XR"
className="absolute inset-0 h-full w-full object-contain"
/>
)}
{(state === 'checking' || state === 'connecting' || state === 'waiting') && ( {(state === 'checking' || state === 'connecting' || state === 'waiting') && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/80"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/80">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />