Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user