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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user