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.
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;
+34 -3
View File
@@ -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<void>;
/** 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<BroadcasterHandle> {
export async function startBroadcast(
stream: MediaStream,
options: { frameSource?: HTMLCanvasElement | null } = {},
): Promise<BroadcasterHandle> {
const code = generateRoomCode();
// Insert room
@@ -35,6 +41,8 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
let channel: RealtimeChannel | null = null;
let activeStream: MediaStream = stream;
let frameSource: HTMLCanvasElement | null = options.frameSource ?? null;
let frameBusy = false;
const handleMessage = async (msg: SignalMessage) => {
if (msg.type === 'viewer-join') {
@@ -43,10 +51,11 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
peers.set(msg.viewerId, pc);
activeStream.getTracks().forEach((t) => 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<BroadcasterHa
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (channel) {
sendSignal(channel, { type: 'offer', viewerId: msg.viewerId, sdp: offer });
await sendSignal(channel, { type: 'offer', viewerId: msg.viewerId, sdp: offer });
}
notify();
updateViewerCount(code, peers.size);
@@ -97,6 +106,26 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
};
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
const attachEndedListeners = (s: MediaStream) => {
@@ -114,6 +143,7 @@ export async function startBroadcast(stream: MediaStream): Promise<BroadcasterHa
stopped = true;
peers.forEach((pc) => 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<BroadcasterHa
code,
stop,
replaceVideoTrack,
setFrameSource: (canvas) => { frameSource = canvas; },
onViewerCountChange: (cb) => {
listeners.add(cb);
cb(peers.size);
+37 -4
View File
@@ -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<RealtimeChannel, Promise<void>>();
export function joinSignalingChannel(
code: string,
onMessage: (msg: SignalMessage) => void,
): 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}`, {
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<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 {
+15 -11
View File
@@ -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<ViewerHandle> {
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);
}
+18 -4
View File
@@ -14,6 +14,7 @@ export default function Watch() {
const [state, setState] = useState<ConnState>('checking');
const [error, setError] = useState<string>('');
const [fullscreen, setFullscreen] = useState(false);
const [fallbackFrame, setFallbackFrame] = useState<string>('');
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 && (
<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') && (
<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" />