Adicionou WebRTC para espelhamento
X-Lovable-Edit-ID: edt-295b2d59-76b3-4f08-ad43-cba59525418b Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
VITE_SUPABASE_PROJECT_ID="mebgazuntzkcnkifftbe"
|
||||||
|
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1lYmdhenVudHprY25raWZmdGJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg3Mzg3NTMsImV4cCI6MjA5NDMxNDc1M30.lf3sYc11q9Rl7yk77rIOkacHytG_VKcOBu5kqw0CgcY"
|
||||||
|
VITE_SUPABASE_URL="https://mebgazuntzkcnkifftbe.supabase.co"
|
||||||
+100
-78
@@ -1,96 +1,118 @@
|
|||||||
## Objetivo
|
# Plano — Compartilhar tela do Quest 3 via link público
|
||||||
|
|
||||||
Adicionar um botão **"Escala"** com presets de redução (1:2, 1:4, 1:8, 1:10, 1:20, 1:33, 1:50, 1:100, 1:200) e ampliação (2:1, 4:1, 10:1), disponível tanto no Viewer 2D quanto no XR. Ao mudar a escala, o app ajusta automaticamente as ferramentas que dependem de tamanho real (medições, sensibilidade do ajuste fino, snap, hover auto-detect), mantendo todos os números exibidos em **mm reais da peça**.
|
Permitir que o inspetor no Quest gere um link tipo `tracksteelxr.lovable.app/watch/AB12CD`, mande no WhatsApp/email, e até 3 espectadores assistam ao vivo (vídeo + áudio do Quest) em qualquer celular ou PC.
|
||||||
|
|
||||||
## Conceito chave
|
## Como vai funcionar para o usuário
|
||||||
|
|
||||||
- "1:N" significa: o modelo é renderizado **N vezes menor** que o real (renderScale = 1/N).
|
**No Quest (broadcaster):**
|
||||||
- "N:1" significa: renderizado N vezes maior (renderScale = N).
|
1. No HUD do `/xr` aparece novo botão **"Compartilhar"** (ícone Share/Cast)
|
||||||
- Os dados do modelo continuam em mm reais. Só o `scale` aplicado ao `<group>` muda.
|
2. Ao clicar: app gera código de 6 caracteres, mostra QR + link encurtado
|
||||||
- **Medições, sensibilidades, indicadores e relatório PDF continuam exibindo mm REAIS** — o app multiplica/divide pelo fator quando necessário.
|
3. Botões "Copiar link" e "Compartilhar" (Web Share API → abre WhatsApp/email do sistema)
|
||||||
|
4. Indicador vermelho 🔴 LIVE fica visível enquanto transmite, com contador de viewers
|
||||||
|
5. Botão "Encerrar" para parar
|
||||||
|
|
||||||
## Mudanças
|
**No celular/PC (viewer):**
|
||||||
|
1. Abre o link → tela cheia com vídeo da sessão XR
|
||||||
|
2. Sem login, sem instalar nada
|
||||||
|
3. Mostra "Aguardando inspetor…" se a sala não estiver ativa
|
||||||
|
4. Read-only: só assiste
|
||||||
|
|
||||||
### 1. Store (`useModelStore.ts`)
|
## Arquitetura técnica
|
||||||
|
|
||||||
Adicionar:
|
|
||||||
```ts
|
|
||||||
scaleRatio: { label: '1:1', factor: 1 } // factor = renderScale
|
|
||||||
setScaleRatio: (r) => void
|
|
||||||
```
|
|
||||||
Lista de presets exportada (label + factor).
|
|
||||||
|
|
||||||
`fineTuning.scale` continua existindo para o gesto two-hand do XR; o novo `scaleRatio` é aplicado **por cima** (multiplicado) no grupo do modelo.
|
|
||||||
|
|
||||||
### 2. Cálculo de medições (`addMeasurePoint`)
|
|
||||||
|
|
||||||
Pontos A/B vêm em coordenadas world já escaladas. Para reportar mm reais:
|
|
||||||
```
|
|
||||||
distanceMM_real = distanciaWorld_mm / scaleRatio.factor
|
|
||||||
```
|
|
||||||
Atualizar a função `addMeasurePoint` para dividir pela escala atual antes de salvar `distanceMM`. Tooltips e PDF continuam usando o valor salvo (sem mudança).
|
|
||||||
|
|
||||||
### 3. Ajuste fino (`FineTuningControls` + XRHud joystick)
|
|
||||||
|
|
||||||
`POSITION_STEP` deve representar **1 mm real**. Aplicar:
|
|
||||||
```
|
|
||||||
worldStep = POSITION_STEP * scaleRatio.factor
|
|
||||||
```
|
|
||||||
Isso garante que clicar "+" desloca visualmente proporcional à escala (em escala 1:100, mexer "1 mm real" desloca 0,01 mm na cena — coerente).
|
|
||||||
|
|
||||||
Mesmo tratamento para o joystick no XR (incremento por frame multiplicado por `scaleRatio.factor`).
|
|
||||||
|
|
||||||
### 4. Auto-detect (hover hole/edge) e snap
|
|
||||||
|
|
||||||
Os limiares de detecção de furo/aresta e raio de snap são em metros world. Multiplicar pelo `scaleRatio.factor` para que continuem capturando os mesmos elementos físicos da peça.
|
|
||||||
|
|
||||||
### 5. ModelViewer
|
|
||||||
|
|
||||||
No `<group>` do modelo, multiplicar o scale final:
|
|
||||||
```
|
|
||||||
finalScale = fineTuning.scale * scaleRatio.factor
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. UI — novo componente `ScaleSelector`
|
|
||||||
|
|
||||||
Botão com ícone de régua + label atual ("1:1", "1:50"…). Ao clicar abre um Popover/Sheet com grid:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Reduzir Ampliar
|
Quest 3 (broadcaster) Lovable Cloud Viewers (celular/PC)
|
||||||
[1:1*] [1:2] [1:4] [1:8] [2:1]
|
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
|
||||||
[1:10] [1:20] [1:33] [1:50] [4:1]
|
│ /xr │ │ Supabase Realtime │ │ /watch/AB12CD │
|
||||||
[1:100] [1:200] [10:1]
|
│ - canvas WebXR │ ──SDP──▶│ (signaling channel) │◀──SDP── │ - <video> tag │
|
||||||
|
│ - getDisplayMedia│ │ rooms table │ │ - peer connection│
|
||||||
|
│ - RTCPeerConn x3 │◀════════ ICE candidates ═════════════▶ │ │
|
||||||
|
└──────────────────┘ │ viewers count │ └──────────────────┘
|
||||||
|
║ └─────────────────────┘ ▲
|
||||||
|
╚════════ WebRTC P2P media stream (vídeo H264) ════════════════╝
|
||||||
```
|
```
|
||||||
|
|
||||||
Toast ao trocar: *"Escala 1:50 — medições e controles ajustados para mm reais"*.
|
**Stack:**
|
||||||
|
- **Lovable Cloud** (precisa habilitar): tabela `share_rooms` + canal Realtime para signaling
|
||||||
|
- **WebRTC nativo do browser** (`RTCPeerConnection`) — sem libs pesadas
|
||||||
|
- **Captura no Quest:** `navigator.mediaDevices.getDisplayMedia()` no browser do Quest captura a janela atual (inclui o canvas 3D). Em sessão XR imersiva, alternativa é usar `canvas.captureStream(30)` direto do canvas WebGL — funciona melhor e não precisa de prompt
|
||||||
|
- **Sem servidor de mídia (SFU):** P2P 1→3 viewers, banda do Quest aguenta tranquilo (~2 Mbps por viewer)
|
||||||
|
|
||||||
Inserir o componente em:
|
## Estrutura de arquivos
|
||||||
- **`ViewerControls`** (canto dos controles flutuantes do Viewer 2D)
|
|
||||||
- **`XRHud`** (linha de ferramentas, próximo ao toggle "Escala ON/OFF" two-hand)
|
|
||||||
|
|
||||||
### 7. MeasurementsList & FineTuningControls
|
**Novos:**
|
||||||
|
- `src/pages/Watch.tsx` — página pública do viewer (rota `/watch/:code`)
|
||||||
|
- `src/components/ShareButton.tsx` — botão + dialog com QR/link no HUD
|
||||||
|
- `src/lib/webrtc/broadcaster.ts` — lógica do lado Quest (cria offer, gerencia múltiplos peers)
|
||||||
|
- `src/lib/webrtc/viewer.ts` — lógica do lado viewer (recebe offer, monta `<video>`)
|
||||||
|
- `src/lib/webrtc/signaling.ts` — wrapper sobre Supabase Realtime para troca SDP/ICE
|
||||||
|
|
||||||
Adicionar pequeno badge "Escala atual: 1:50" no topo dos painéis de Medições e Ajuste Fino para o usuário lembrar que os valores exibidos são mm reais.
|
**Editados:**
|
||||||
|
- `src/App.tsx` — adicionar rota `/watch/:code` (pública, fora do layout XR)
|
||||||
|
- `src/components/XRHud.tsx` — adicionar `<ShareButton />` na toolbar
|
||||||
|
- `src/pages/XRSession.tsx` — botão também visível fora do HUD
|
||||||
|
- `src/stores/useModelStore.ts` — estado `shareRoom: { code, viewerCount, isLive }`
|
||||||
|
|
||||||
## O que NÃO muda
|
## Schema do banco (Lovable Cloud)
|
||||||
|
|
||||||
- Gesto two-hand de escala (`allowScale`) continua funcionando para ajuste livre — mas se o usuário usar o preset, o gesto reseta para 1.0 e o preset assume.
|
```sql
|
||||||
- Relatório PDF: registra a escala atual no cabeçalho.
|
create table public.share_rooms (
|
||||||
- Grid e checklist: sem alteração.
|
code text primary key, -- 6 chars, ex: 'AB12CD'
|
||||||
|
created_at timestamptz default now(),
|
||||||
|
expires_at timestamptz default now() + interval '4 hours',
|
||||||
|
is_active boolean default true,
|
||||||
|
viewer_count int default 0
|
||||||
|
);
|
||||||
|
|
||||||
## Arquivos tocados
|
-- RLS: leitura pública (viewers precisam saber se sala existe), escrita só anon controlada
|
||||||
|
alter table public.share_rooms enable row level security;
|
||||||
|
create policy "anyone reads active rooms" on public.share_rooms
|
||||||
|
for select using (is_active and expires_at > now());
|
||||||
|
create policy "anyone creates room" on public.share_rooms
|
||||||
|
for insert with check (true);
|
||||||
|
create policy "anyone updates count" on public.share_rooms
|
||||||
|
for update using (true);
|
||||||
|
```
|
||||||
|
|
||||||
- `src/stores/useModelStore.ts` — novo estado + ajuste em `addMeasurePoint`
|
Signaling propriamente dito (SDP offers/answers, ICE candidates) acontece via **Supabase Realtime broadcast channel** `room:${code}` — não persiste em tabela, é efêmero.
|
||||||
- `src/components/ScaleSelector.tsx` — novo (popover com presets)
|
|
||||||
- `src/components/ViewerControls.tsx` — incluir `<ScaleSelector />`
|
|
||||||
- `src/components/XRHud.tsx` — incluir `<ScaleSelector />` + multiplicar passos do joystick
|
|
||||||
- `src/components/FineTuningControls.tsx` — multiplicar passos pela escala + badge
|
|
||||||
- `src/components/MeasurementsList.tsx` — badge da escala
|
|
||||||
- `src/components/three/ModelViewer.tsx` — aplicar `scaleRatio.factor` no group + ajustar threshold de snap/hover
|
|
||||||
- `src/lib/generateReport.ts` — incluir escala no cabeçalho do PDF
|
|
||||||
|
|
||||||
## Validação
|
## STUN/TURN
|
||||||
|
|
||||||
- Carregar viga demo, medir uma aresta conhecida em 1:1 → trocar para 1:10 → remedir mesma aresta → valor em mm deve permanecer igual.
|
- **STUN público do Google** (`stun:stun.l.google.com:19302`) — grátis, resolve ~80% dos casos (NAT comum)
|
||||||
- Em 1:50, clicar "+" no ajuste fino X uma vez → modelo desloca visualmente pouquíssimo (esperado), mas valor exibido sobe 1 mm.
|
- **TURN:** se inspetor estiver atrás de NAT simétrico (raro em fábrica), conexão falha. Para v1 deixamos só STUN. Se precisar TURN depois, integramos Twilio TURN (~$0,40/GB) ou Metered.ca (free tier 50GB/mês)
|
||||||
- No XR, joystick em 1:100 não "voa" o modelo a metros de distância.
|
|
||||||
|
## Captura no Quest — detalhes importantes
|
||||||
|
|
||||||
|
Tem dois cenários:
|
||||||
|
1. **Fora da sessão XR** (modo viewer 2D): `canvas.captureStream(30)` — fácil, funciona já
|
||||||
|
2. **Dentro da sessão XR imersiva** (passthrough AR): aqui o canvas é renderizado para o display do headset, não para a tela visível. Solução: usar `XRWebGLLayer.framebuffer` e renderizar uma view extra "spectator" para um canvas offscreen que vira o stream. **Esta parte tem risco** — pode precisar fallback para `getDisplayMedia()` que pede permissão do usuário no Quest
|
||||||
|
|
||||||
|
Plano: começar com cenário 1 (já cobre uso fora do AR imersivo), e cenário 2 numa segunda iteração se necessário.
|
||||||
|
|
||||||
|
## Limitações honestas
|
||||||
|
|
||||||
|
- **Latência:** 200-500ms (não é instantâneo, mas serve para acompanhamento)
|
||||||
|
- **Sem áudio do microfone do Quest** na v1 (adicionar depois com `getUserMedia({audio:true})`)
|
||||||
|
- **Sala expira em 4h** — geração de novo código se sessão durar mais
|
||||||
|
- **Link público:** qualquer um com a URL vê. Se vazar, código pode ser revogado pelo inspetor
|
||||||
|
- **Não grava** — só transmissão ao vivo. Gravação seria feature separada
|
||||||
|
|
||||||
|
## Pré-requisito
|
||||||
|
|
||||||
|
Precisa **habilitar Lovable Cloud** (atualmente o projeto não tem). Vou pedir confirmação antes de habilitar, pois cria infraestrutura backend.
|
||||||
|
|
||||||
|
## Escopo desta implementação
|
||||||
|
|
||||||
|
✅ Botão "Compartilhar" no HUD do XR + tela inicial
|
||||||
|
✅ Geração de código + QR + link curto + Web Share API
|
||||||
|
✅ Página `/watch/:code` pública com vídeo
|
||||||
|
✅ Signaling via Supabase Realtime
|
||||||
|
✅ WebRTC P2P 1→3 viewers
|
||||||
|
✅ Contador de viewers em tempo real para o broadcaster
|
||||||
|
✅ Captura via `canvas.captureStream` (cenário fora do XR imersivo)
|
||||||
|
❌ Captura dentro da sessão XR imersiva (segunda iteração)
|
||||||
|
❌ Áudio do mic (segunda iteração)
|
||||||
|
❌ Chat / interação (você optou por read-only)
|
||||||
|
❌ TURN server (só se aparecer problema de NAT)
|
||||||
|
❌ Gravação
|
||||||
|
|
||||||
|
Aprovando o plano, eu habilito o Lovable Cloud e implemento.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"@react-three/drei": "^9.122.0",
|
"@react-three/drei": "^9.122.0",
|
||||||
"@react-three/fiber": "^8.18.0",
|
"@react-three/fiber": "^8.18.0",
|
||||||
"@react-three/xr": "^6.6.29",
|
"@react-three/xr": "^6.6.29",
|
||||||
|
"@supabase/supabase-js": "^2.105.4",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@types/three": "^0.160.0",
|
"@types/three": "^0.160.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"jspdf": "^4.2.0",
|
"jspdf": "^4.2.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import Viewer from "./pages/Viewer";
|
import Viewer from "./pages/Viewer";
|
||||||
import XRSession from "./pages/XRSession";
|
import XRSession from "./pages/XRSession";
|
||||||
|
import Watch from "./pages/Watch";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -20,6 +21,7 @@ const App = () => (
|
|||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/viewer" element={<Viewer />} />
|
<Route path="/viewer" element={<Viewer />} />
|
||||||
<Route path="/xr" element={<XRSession />} />
|
<Route path="/xr" element={<XRSession />} />
|
||||||
|
<Route path="/watch/:code" element={<Watch />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Share2, Copy, Users, X, Loader2 } from 'lucide-react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { startBroadcast, type BroadcasterHandle } from '@/lib/webrtc/broadcaster';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface ShareButtonProps {
|
||||||
|
variant?: 'default' | 'compact';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareButton({ variant = 'default' }: ShareButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [handle, setHandle] = useState<BroadcasterHandle | null>(null);
|
||||||
|
const [viewerCount, setViewerCount] = useState(0);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
const link = handle ? `${window.location.origin}/watch/${handle.code}` : '';
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
// Try canvas.captureStream first (works inside the page, no permission prompt)
|
||||||
|
const canvas = document.querySelector('canvas') as HTMLCanvasElement | null;
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
|
if (canvas && typeof canvas.captureStream === 'function') {
|
||||||
|
stream = canvas.captureStream(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: getDisplayMedia (asks user permission, captures the browser tab)
|
||||||
|
if (!stream || stream.getVideoTracks().length === 0) {
|
||||||
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { frameRate: 30 },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
streamRef.current = stream;
|
||||||
|
const h = await startBroadcast(stream);
|
||||||
|
setHandle(h);
|
||||||
|
h.onViewerCountChange(setViewerCount);
|
||||||
|
toast.success(`Sala criada: ${h.code}`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
toast.error(`Falha ao iniciar transmissão: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
if (handle) await handle.stop();
|
||||||
|
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
setHandle(null);
|
||||||
|
setViewerCount(0);
|
||||||
|
toast.info('Transmissão encerrada');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (handle) handle.stop();
|
||||||
|
if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
toast.success('Link copiado');
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareLink = async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'TrackSteelXR — Sessão ao vivo',
|
||||||
|
text: 'Acompanhe a inspeção ao vivo:',
|
||||||
|
url: link,
|
||||||
|
});
|
||||||
|
} catch {/* user cancelled */}
|
||||||
|
} else {
|
||||||
|
copyLink();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCompact = variant === 'compact';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={handle ? 'default' : 'outline'}
|
||||||
|
size={isCompact ? 'sm' : 'sm'}
|
||||||
|
className={isCompact ? 'h-8 gap-1.5 text-[10px] font-mono' : 'gap-2'}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
title="Compartilhar tela ao vivo"
|
||||||
|
>
|
||||||
|
<Share2 className={isCompact ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
|
||||||
|
{handle ? (
|
||||||
|
<>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
|
||||||
|
</span>
|
||||||
|
LIVE {viewerCount > 0 && `· ${viewerCount}`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Compartilhar'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 font-mono text-sm">
|
||||||
|
<Share2 className="h-4 w-4 text-primary" />
|
||||||
|
Compartilhar tela ao vivo
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!handle ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Gere um link público para que até 5 pessoas acompanhem sua sessão ao vivo
|
||||||
|
pelo celular ou PC. Não precisa de login. A sala expira em 4h.
|
||||||
|
</p>
|
||||||
|
<Button onClick={start} disabled={starting} className="w-full gap-2">
|
||||||
|
{starting ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin" /> Iniciando…</>
|
||||||
|
) : (
|
||||||
|
<><Share2 className="h-4 w-4" /> Gerar link de acesso</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="rounded-lg bg-white p-3">
|
||||||
|
<QRCodeSVG value={link} size={160} level="M" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">CÓDIGO</span>
|
||||||
|
<span className="font-mono text-lg font-bold tracking-widest text-primary">
|
||||||
|
{handle.code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 rounded bg-background px-2 py-1.5 border">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={link}
|
||||||
|
className="flex-1 bg-transparent text-[10px] font-mono outline-none"
|
||||||
|
/>
|
||||||
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={copyLink}>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Users className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="font-mono text-[11px]">
|
||||||
|
{viewerCount} {viewerCount === 1 ? 'espectador' : 'espectadores'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-red-500">LIVE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button onClick={shareLink} className="gap-2">
|
||||||
|
<Share2 className="h-4 w-4" /> Enviar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={stop} variant="destructive" className="gap-2">
|
||||||
|
<X className="h-4 w-4" /> Encerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useModelStore } from '@/stores/useModelStore';
|
|||||||
import { InspectionChecklist } from '@/components/InspectionChecklist';
|
import { InspectionChecklist } from '@/components/InspectionChecklist';
|
||||||
import { ScaleSelector } from '@/components/ScaleSelector';
|
import { ScaleSelector } from '@/components/ScaleSelector';
|
||||||
import { CloudLoader } from '@/components/CloudLoader';
|
import { CloudLoader } from '@/components/CloudLoader';
|
||||||
|
import { ShareButton } from '@/components/ShareButton';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const POSITION_STEP = 0.001;
|
const POSITION_STEP = 0.001;
|
||||||
@@ -318,6 +319,9 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
|||||||
{/* Cloud loader (acessível também no AR) */}
|
{/* Cloud loader (acessível também no AR) */}
|
||||||
<CloudLoader compact />
|
<CloudLoader compact />
|
||||||
|
|
||||||
|
{/* Share live screen */}
|
||||||
|
<ShareButton variant="compact" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={freeMove ? 'default' : 'outline'}
|
variant={freeMove ? 'default' : 'outline'}
|
||||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// This file is automatically generated. Do not edit it directly.
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database } from './types';
|
||||||
|
|
||||||
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
|
||||||
|
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
|
||||||
|
|
||||||
|
// Import the supabase client like this:
|
||||||
|
// import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
|
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
|
||||||
|
auth: {
|
||||||
|
storage: localStorage,
|
||||||
|
persistSession: true,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
export type Json =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
// Allows to automatically instantiate createClient with right options
|
||||||
|
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||||
|
__InternalSupabase: {
|
||||||
|
PostgrestVersion: "14.5"
|
||||||
|
}
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
share_rooms: {
|
||||||
|
Row: {
|
||||||
|
code: string
|
||||||
|
created_at: string
|
||||||
|
expires_at: string
|
||||||
|
is_active: boolean
|
||||||
|
viewer_count: number
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
code: string
|
||||||
|
created_at?: string
|
||||||
|
expires_at?: string
|
||||||
|
is_active?: boolean
|
||||||
|
viewer_count?: number
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
code?: string
|
||||||
|
created_at?: string
|
||||||
|
expires_at?: string
|
||||||
|
is_active?: boolean
|
||||||
|
viewer_count?: number
|
||||||
|
}
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
CompositeTypes: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
|
||||||
|
|
||||||
|
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
|
||||||
|
|
||||||
|
export type Tables<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||||
|
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])
|
||||||
|
? (DefaultSchema["Tables"] &
|
||||||
|
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Row: infer R
|
||||||
|
}
|
||||||
|
? R
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesInsert<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Insert: infer I
|
||||||
|
}
|
||||||
|
? I
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type TablesUpdate<
|
||||||
|
DefaultSchemaTableNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Tables"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaTableNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||||
|
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||||
|
Update: infer U
|
||||||
|
}
|
||||||
|
? U
|
||||||
|
: never
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type Enums<
|
||||||
|
DefaultSchemaEnumNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["Enums"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||||
|
: never = never,
|
||||||
|
> = DefaultSchemaEnumNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||||
|
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||||
|
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type CompositeTypes<
|
||||||
|
PublicCompositeTypeNameOrOptions extends
|
||||||
|
| keyof DefaultSchema["CompositeTypes"]
|
||||||
|
| { schema: keyof DatabaseWithoutInternals },
|
||||||
|
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||||
|
: never = never,
|
||||||
|
> = PublicCompositeTypeNameOrOptions extends {
|
||||||
|
schema: keyof DatabaseWithoutInternals
|
||||||
|
}
|
||||||
|
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||||
|
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||||
|
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||||
|
: never
|
||||||
|
|
||||||
|
export const Constants = {
|
||||||
|
public: {
|
||||||
|
Enums: {},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import {
|
||||||
|
generateRoomCode,
|
||||||
|
joinSignalingChannel,
|
||||||
|
sendSignal,
|
||||||
|
ICE_SERVERS,
|
||||||
|
type SignalMessage,
|
||||||
|
} from './signaling';
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export interface BroadcasterHandle {
|
||||||
|
code: string;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
onViewerCountChange: (cb: (n: number) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
const code = generateRoomCode();
|
||||||
|
|
||||||
|
// Insert room
|
||||||
|
const { error: insErr } = await supabase.from('share_rooms').insert({ code, is_active: true });
|
||||||
|
if (insErr) throw new Error(`Falha ao criar sala: ${insErr.message}`);
|
||||||
|
|
||||||
|
const peers = new Map<string, RTCPeerConnection>();
|
||||||
|
const listeners = new Set<(n: number) => void>();
|
||||||
|
const notify = () => listeners.forEach((cb) => cb(peers.size));
|
||||||
|
|
||||||
|
let channel: RealtimeChannel | null = null;
|
||||||
|
|
||||||
|
const handleMessage = async (msg: SignalMessage) => {
|
||||||
|
if (msg.type === 'viewer-join') {
|
||||||
|
if (peers.size >= MAX_VIEWERS) return;
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||||
|
peers.set(msg.viewerId, pc);
|
||||||
|
|
||||||
|
stream.getTracks().forEach((t) => pc.addTrack(t, stream));
|
||||||
|
|
||||||
|
pc.onicecandidate = (ev) => {
|
||||||
|
if (ev.candidate && channel) {
|
||||||
|
sendSignal(channel, {
|
||||||
|
type: 'ice',
|
||||||
|
viewerId: msg.viewerId,
|
||||||
|
from: 'broadcaster',
|
||||||
|
candidate: ev.candidate.toJSON(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) {
|
||||||
|
peers.delete(msg.viewerId);
|
||||||
|
pc.close();
|
||||||
|
notify();
|
||||||
|
updateViewerCount(code, peers.size);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
if (channel) {
|
||||||
|
sendSignal(channel, { type: 'offer', viewerId: msg.viewerId, sdp: offer });
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
updateViewerCount(code, peers.size);
|
||||||
|
} else if (msg.type === 'answer') {
|
||||||
|
const pc = peers.get(msg.viewerId);
|
||||||
|
if (pc && pc.signalingState !== 'stable') {
|
||||||
|
await pc.setRemoteDescription(msg.sdp);
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'ice' && msg.from === 'viewer') {
|
||||||
|
const pc = peers.get(msg.viewerId);
|
||||||
|
if (pc) {
|
||||||
|
try {
|
||||||
|
await pc.addIceCandidate(msg.candidate);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[broadcaster] addIceCandidate failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'viewer-leave') {
|
||||||
|
const pc = peers.get(msg.viewerId);
|
||||||
|
if (pc) {
|
||||||
|
pc.close();
|
||||||
|
peers.delete(msg.viewerId);
|
||||||
|
notify();
|
||||||
|
updateViewerCount(code, peers.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel = joinSignalingChannel(code, handleMessage);
|
||||||
|
|
||||||
|
// Stop when the underlying stream ends
|
||||||
|
stream.getTracks().forEach((t) => {
|
||||||
|
t.addEventListener('ended', () => stop());
|
||||||
|
});
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const stop = async () => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
peers.forEach((pc) => pc.close());
|
||||||
|
peers.clear();
|
||||||
|
notify();
|
||||||
|
if (channel) await supabase.removeChannel(channel);
|
||||||
|
await supabase.from('share_rooms').update({ is_active: false, viewer_count: 0 }).eq('code', code);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
stop,
|
||||||
|
onViewerCountChange: (cb) => {
|
||||||
|
listeners.add(cb);
|
||||||
|
cb(peers.size);
|
||||||
|
return () => listeners.delete(cb);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateViewerCount(code: string, n: number) {
|
||||||
|
await supabase.from('share_rooms').update({ viewer_count: n }).eq('code', code);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export type SignalMessage =
|
||||||
|
| { type: 'viewer-join'; viewerId: string }
|
||||||
|
| { 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 };
|
||||||
|
|
||||||
|
export function joinSignalingChannel(
|
||||||
|
code: string,
|
||||||
|
onMessage: (msg: SignalMessage) => void,
|
||||||
|
): RealtimeChannel {
|
||||||
|
const channel = supabase.channel(`share:${code}`, {
|
||||||
|
config: { broadcast: { self: false, ack: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
channel
|
||||||
|
.on('broadcast', { event: 'signal' }, ({ payload }) => onMessage(payload as SignalMessage))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendSignal(channel: RealtimeChannel, msg: SignalMessage) {
|
||||||
|
return channel.send({ type: 'broadcast', event: 'signal', payload: msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRoomCode(): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // unambiguous
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 6; i++) out += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ICE_SERVERS: RTCIceServer[] = [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
joinSignalingChannel,
|
||||||
|
sendSignal,
|
||||||
|
ICE_SERVERS,
|
||||||
|
type SignalMessage,
|
||||||
|
} from './signaling';
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export interface ViewerHandle {
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to a broadcaster's room and renders the incoming stream into the given <video>.
|
||||||
|
*/
|
||||||
|
export async function joinAsViewer(
|
||||||
|
code: string,
|
||||||
|
videoEl: HTMLVideoElement,
|
||||||
|
onState: (s: 'connecting' | 'waiting' | 'live' | 'error', detail?: string) => void,
|
||||||
|
): Promise<ViewerHandle> {
|
||||||
|
const viewerId = crypto.randomUUID();
|
||||||
|
let pc: RTCPeerConnection | null = null;
|
||||||
|
let channel: RealtimeChannel | null = null;
|
||||||
|
|
||||||
|
onState('connecting');
|
||||||
|
|
||||||
|
const handleMessage = async (msg: SignalMessage) => {
|
||||||
|
if (msg.type === 'offer' && msg.viewerId === viewerId) {
|
||||||
|
pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||||
|
|
||||||
|
pc.ontrack = (ev) => {
|
||||||
|
videoEl.srcObject = ev.streams[0];
|
||||||
|
videoEl.play().catch(() => {/* autoplay may need user gesture */});
|
||||||
|
onState('live');
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onicecandidate = (ev) => {
|
||||||
|
if (ev.candidate && channel) {
|
||||||
|
sendSignal(channel, {
|
||||||
|
type: 'ice',
|
||||||
|
viewerId,
|
||||||
|
from: 'viewer',
|
||||||
|
candidate: ev.candidate.toJSON(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
if (pc && ['failed', 'disconnected'].includes(pc.connectionState)) {
|
||||||
|
onState('error', 'Conexão perdida');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(msg.sdp);
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
if (channel) sendSignal(channel, { type: 'answer', viewerId, sdp: answer });
|
||||||
|
} else if (msg.type === 'ice' && msg.viewerId === viewerId && msg.from === 'broadcaster') {
|
||||||
|
if (pc) {
|
||||||
|
try {
|
||||||
|
await pc.addIceCandidate(msg.candidate);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[viewer] addIceCandidate failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel = joinSignalingChannel(code, handleMessage);
|
||||||
|
|
||||||
|
// Wait a tick for subscribe, then announce join
|
||||||
|
setTimeout(() => {
|
||||||
|
if (channel) {
|
||||||
|
sendSignal(channel, { type: 'viewer-join', viewerId });
|
||||||
|
onState('waiting');
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
if (channel) {
|
||||||
|
try { sendSignal(channel, { type: 'viewer-leave', viewerId }); } catch {}
|
||||||
|
const { supabase } = await import('@/integrations/supabase/client');
|
||||||
|
await supabase.removeChannel(channel);
|
||||||
|
}
|
||||||
|
if (pc) pc.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { stop };
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { InspectionChecklist } from "@/components/InspectionChecklist";
|
|||||||
import { FineTuningControls } from "@/components/FineTuningControls";
|
import { FineTuningControls } from "@/components/FineTuningControls";
|
||||||
import { MeasurementsList } from "@/components/MeasurementsList";
|
import { MeasurementsList } from "@/components/MeasurementsList";
|
||||||
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
||||||
|
import { ShareButton } from "@/components/ShareButton";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
@@ -49,10 +50,13 @@ const Viewer = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShareButton />
|
||||||
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
||||||
<Glasses className="h-4 w-4" />
|
<Glasses className="h-4 w-4" />
|
||||||
Entrar em Modo XR
|
Entrar em Modo XR
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { Loader2, Maximize, Minimize, AlertCircle, Eye } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { joinAsViewer, type ViewerHandle } from '@/lib/webrtc/viewer';
|
||||||
|
|
||||||
|
type ConnState = 'checking' | 'connecting' | 'waiting' | 'live' | 'error' | 'not-found';
|
||||||
|
|
||||||
|
export default function Watch() {
|
||||||
|
const { code = '' } = useParams();
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const handleRef = useRef<ViewerHandle | null>(null);
|
||||||
|
const [state, setState] = useState<ConnState>('checking');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { data, error: dbErr } = await supabase
|
||||||
|
.from('share_rooms')
|
||||||
|
.select('code, is_active, expires_at')
|
||||||
|
.eq('code', code.toUpperCase())
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (dbErr) {
|
||||||
|
setError(dbErr.message);
|
||||||
|
setState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
setState('not-found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoRef.current) return;
|
||||||
|
try {
|
||||||
|
const handle = await joinAsViewer(code.toUpperCase(), videoRef.current, (s, detail) => {
|
||||||
|
setState(s as ConnState);
|
||||||
|
if (detail) setError(detail);
|
||||||
|
});
|
||||||
|
handleRef.current = handle;
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
setState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (handleRef.current) handleRef.current.stop();
|
||||||
|
};
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const toggleFullscreen = async () => {
|
||||||
|
const el = videoRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
setFullscreen(false);
|
||||||
|
} else {
|
||||||
|
await el.requestFullscreen();
|
||||||
|
setFullscreen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
|
<header className="border-b bg-card/50 backdrop-blur px-4 py-3 flex items-center justify-between">
|
||||||
|
<Link to="/" className="font-mono text-sm font-bold text-foreground">
|
||||||
|
TrackSteel<span className="text-primary">XR</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">SALA</span>
|
||||||
|
<span className="font-mono text-sm font-bold tracking-widest text-primary">
|
||||||
|
{code.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{state === 'live' && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-red-500">AO VIVO</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 relative bg-black flex items-center justify-center">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className="max-w-full max-h-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" />
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
{state === 'checking' && 'Verificando sala…'}
|
||||||
|
{state === 'connecting' && 'Conectando…'}
|
||||||
|
{state === 'waiting' && 'Aguardando o inspetor iniciar…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'not-found' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/90 px-6 text-center">
|
||||||
|
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||||
|
<h2 className="font-mono text-base font-bold">Sala não encontrada</h2>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground max-w-sm">
|
||||||
|
A sala <span className="text-primary">{code.toUpperCase()}</span> não existe,
|
||||||
|
expirou ou foi encerrada pelo inspetor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'error' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/90 px-6 text-center">
|
||||||
|
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||||
|
<h2 className="font-mono text-base font-bold">Erro de conexão</h2>
|
||||||
|
<p className="font-mono text-xs text-muted-foreground max-w-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'live' && (
|
||||||
|
<Button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute bottom-4 right-4 h-9 w-9 bg-card/80 backdrop-blur"
|
||||||
|
>
|
||||||
|
{fullscreen ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="border-t bg-card/50 backdrop-blur px-4 py-2 flex items-center justify-center gap-2">
|
||||||
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
Modo somente visualização · TrackSteelXR
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
project_id = "mebgazuntzkcnkifftbe"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
create table public.share_rooms (
|
||||||
|
code text primary key,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
expires_at timestamptz not null default (now() + interval '4 hours'),
|
||||||
|
is_active boolean not null default true,
|
||||||
|
viewer_count int not null default 0
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.share_rooms enable row level security;
|
||||||
|
|
||||||
|
create policy "anyone can read active rooms"
|
||||||
|
on public.share_rooms for select
|
||||||
|
using (is_active = true and expires_at > now());
|
||||||
|
|
||||||
|
create policy "anyone can create rooms"
|
||||||
|
on public.share_rooms for insert
|
||||||
|
with check (true);
|
||||||
|
|
||||||
|
create policy "anyone can update rooms"
|
||||||
|
on public.share_rooms for update
|
||||||
|
using (true);
|
||||||
Reference in New Issue
Block a user