diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx new file mode 100644 index 0000000..1e13caa --- /dev/null +++ b/src/components/ShareButton.tsx @@ -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(null); + const [viewerCount, setViewerCount] = useState(0); + const streamRef = useRef(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 ( + <> + + + + + + + + Compartilhar tela ao vivo + + + + {!handle ? ( +
+

+ 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. +

+ +
+ ) : ( +
+
+
+ +
+
+ +
+
+ CÓDIGO + + {handle.code} + +
+
+ + +
+
+ +
+
+ + + {viewerCount} {viewerCount === 1 ? 'espectador' : 'espectadores'} + +
+
+ + + + + LIVE +
+
+ +
+ + +
+
+ )} +
+
+ + ); +} diff --git a/src/pages/Watch.tsx b/src/pages/Watch.tsx new file mode 100644 index 0000000..e9897e4 --- /dev/null +++ b/src/pages/Watch.tsx @@ -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(null); + const handleRef = useRef(null); + const [state, setState] = useState('checking'); + const [error, setError] = useState(''); + 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 ( +
+
+ + TrackSteelXR + +
+ SALA + + {code.toUpperCase()} + + {state === 'live' && ( + + + + + + AO VIVO + + )} +
+
+ +
+
+ + +
+ ); +}