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