346 lines
12 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Button } from 'avanza-ui';
import './App.css';
import StudioPortal from './components/Portal/StudioPortal';
import { isAllowedOrigin } from './utils/postMessage';
import { Room } from 'livekit-client';
function App() {
const [isConnected, setIsConnected] = useState(false);
const [credentials, setCredentials] = useState({
serverUrl: import.meta.env.VITE_LIVEKIT_URL || 'ws://localhost:7880',
token: '',
roomName: 'Studio Demo',
});
const [tempToken, setTempToken] = useState('');
const autoAttemptRef = useRef(false);
// external LiveKit Room managed by App when token is received
const roomRef = useRef<Room | null>(null);
const messageSourceRef = useRef<Window | null>(null);
// store the last validated origin that sent a token so we can ACK securely
const lastValidatedOrigin = useRef<string | null>(null);
// Called when the LiveKit room reports connected
const handleRoomConnected = () => {
setIsConnected(true);
// send connected ACK to the validated origin if available
try {
const payload = { type: 'LIVEKIT_ACK', status: 'connected' };
const targetOrigin = lastValidatedOrigin.current || '*';
// Prefer replying directly to the message source window if available
if (messageSourceRef.current && typeof (messageSourceRef.current as any).postMessage === 'function') {
try { (messageSourceRef.current as any).postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
if (window.opener && !window.opener.closed) {
try { window.opener.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
if (window.parent && window.parent !== window) {
try { window.parent.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
};
const handleRoomDisconnected = () => {
setIsConnected(false);
try {
const payload = { type: 'LIVEKIT_ACK', status: 'disconnected' };
const targetOrigin = lastValidatedOrigin.current || '*';
if (window.opener && !window.opener.closed) {
try { window.opener.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
if (window.parent && window.parent !== window) {
try { window.parent.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
} catch (e) {}
// disconnect and clear app-managed room
try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){}
};
// Cleanup app-managed room on unmount
useEffect(() => {
return () => {
try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){}
};
}, []);
// Listen for LIVEKIT_TOKEN posted via postMessage (handshake flow)
useEffect(() => {
// central token handler used by both message events and custom events
const handleIncomingToken = async (payload: any, origin?: string | null, source?: any) => {
try {
const originToUse = origin || null;
if (!isAllowedOrigin(originToUse)) {
return;
}
// store validated origin and message source for ACKs
if (originToUse) lastValidatedOrigin.current = originToUse;
if (source) messageSourceRef.current = source;
if (payload?.url) setCredentials(prev => ({ ...prev, serverUrl: String(payload.url) }));
const receivedToken = String(payload.token || payload?.token);
setTempToken(receivedToken);
// cleanup previous room if exists
try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){}
const newRoom = new Room();
roomRef.current = newRoom;
try {
const sUrl = payload?.url || credentials.serverUrl;
await newRoom.connect(sUrl, receivedToken);
setCredentials(prev => ({ ...prev, token: receivedToken }));
handleRoomConnected();
} catch (err) {
console.error('App-managed room connect failed', err);
}
if (!autoAttemptRef.current) {
autoAttemptRef.current = true;
setTimeout(() => { if (receivedToken) handleConnectWithToken(receivedToken); }, 60);
}
} catch (err) { console.debug('handleIncomingToken error', err); }
};
function onMessage(e: MessageEvent) {
try {
const d = e.data || {};
if (d?.type === 'LIVEKIT_TOKEN' && d.token) {
// call central handler and pass origin/source
handleIncomingToken(d, e.origin || null, e.source);
}
} catch (err) { console.debug('postMessage in App error', err) }
}
window.addEventListener('message', onMessage);
// Also listen for the custom event dispatched by main.tsx
function onCustomToken(e: any) {
try {
const detail = e?.detail || (window as any).__AVANZACAST_PENDING_TOKEN || null;
// attempt to recover origin/source from globals set by main.tsx if present
const lastMsg = (window as any).__AVZ_LAST_MSG_SOURCE || null;
const origin = lastMsg?.origin || null;
const source = lastMsg?.source || null;
if (detail && detail.token) handleIncomingToken(detail, origin, source);
} catch(err) { console.debug('custom token handler error', err); }
}
window.addEventListener('avz:livekit:token', onCustomToken as EventListener);
return () => {
window.removeEventListener('message', onMessage);
window.removeEventListener('avz:livekit:token', onCustomToken as EventListener);
};
}, []);
function handleConnectWithToken(tokenVal: string) {
if (tokenVal && tokenVal.trim()) {
setCredentials(prev => ({ ...prev, token: tokenVal }));
setIsConnected(true);
}
}
const handleConnect = () => {
if (tempToken.trim()) {
setCredentials(prev => ({ ...prev, token: tempToken }));
setIsConnected(true);
}
};
// Update a global #status element and notify opener/parent when connected — helps E2E tests detect ACK/state
useEffect(() => {
try {
const setStatus = (txt: string) => {
try {
let el = document.getElementById('status');
if (!el) {
el = document.createElement('div');
el.id = 'status';
el.style.position = 'fixed';
el.style.right = '12px';
el.style.top = '12px';
el.style.padding = '8px 12px';
el.style.background = 'rgba(16,185,129,0.12)';
el.style.color = '#10B981';
el.style.borderRadius = '6px';
el.style.zIndex = '9999';
document.body.appendChild(el);
}
el.textContent = txt;
} catch (e) { /* ignore */ }
};
if (isConnected) {
setStatus('Conectado');
} else {
setStatus('Esperando token...');
}
} catch (e) {}
}, [isConnected]);
if (!isConnected) {
return (
<div className="studio-theme" style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px'
}}>
<div style={{
maxWidth: '500px',
width: '100%',
background: 'var(--studio-bg-secondary)',
padding: '32px',
borderRadius: 'var(--studio-radius-lg)',
boxShadow: 'var(--studio-shadow-lg)'
}}>
<h1 style={{
fontSize: 'var(--studio-text-2xl)',
fontWeight: 'var(--studio-font-bold)',
marginBottom: '8px',
color: 'var(--studio-text-primary)'
}}>
Studio Panel - AvanzaCast
</h1>
<p style={{
fontSize: 'var(--studio-text-base)',
color: 'var(--studio-text-muted)',
marginBottom: '24px'
}}>
Ingresa tus credenciales de LiveKit para comenzar
</p>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
fontSize: 'var(--studio-text-sm)',
fontWeight: 'var(--studio-font-medium)',
marginBottom: '8px',
color: 'var(--studio-text-secondary)'
}}>
LiveKit Server URL
</label>
<input
type="text"
value={credentials.serverUrl}
onChange={(e) => setCredentials(prev => ({ ...prev, serverUrl: e.target.value }))}
style={{
width: '100%',
padding: '10px 12px',
background: 'var(--studio-bg-primary)',
border: '1px solid var(--studio-border)',
borderRadius: 'var(--studio-radius-md)',
color: 'var(--studio-text-primary)',
fontSize: 'var(--studio-text-base)',
fontFamily: 'var(--studio-font-family)'
}}
placeholder="ws://localhost:7880"
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
fontSize: 'var(--studio-text-sm)',
fontWeight: 'var(--studio-font-medium)',
marginBottom: '8px',
color: 'var(--studio-text-secondary)'
}}>
Room Name
</label>
<input
type="text"
value={credentials.roomName}
onChange={(e) => setCredentials(prev => ({ ...prev, roomName: e.target.value }))}
style={{
width: '100%',
padding: '10px 12px',
background: 'var(--studio-bg-primary)',
border: '1px solid var(--studio-border)',
borderRadius: 'var(--studio-radius-md)',
color: 'var(--studio-text-primary)',
fontSize: 'var(--studio-text-base)',
fontFamily: 'var(--studio-font-family)'
}}
placeholder="Studio Demo"
/>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
fontSize: 'var(--studio-text-sm)',
fontWeight: 'var(--studio-font-medium)',
marginBottom: '8px',
color: 'var(--studio-text-secondary)'
}}>
Access Token
</label>
<textarea
value={tempToken}
onChange={(e) => setTempToken(e.target.value)}
rows={4}
style={{
width: '100%',
padding: '10px 12px',
background: 'var(--studio-bg-primary)',
border: '1px solid var(--studio-border)',
borderRadius: 'var(--studio-radius-md)',
color: 'var(--studio-text-primary)',
fontSize: 'var(--studio-text-sm)',
fontFamily: 'monospace',
resize: 'vertical'
}}
placeholder="Paste your LiveKit token here..."
/>
</div>
<Button
variant="primary"
fullWidth
onClick={handleConnect}
disabled={!tempToken.trim()}
>
Conectar al Estudio
</Button>
<div style={{
marginTop: '24px',
padding: '16px',
background: 'var(--studio-bg-tertiary)',
borderRadius: 'var(--studio-radius-md)',
fontSize: 'var(--studio-text-sm)',
color: 'var(--studio-text-muted)'
}}>
<strong style={{ color: 'var(--studio-text-secondary)' }}>Nota:</strong> Necesitas un token válido de LiveKit para conectar.
<br />
<a
href="https://docs.livekit.io/home/get-started/authentication/"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--studio-accent)', textDecoration: 'none' }}
>
Aprende cómo generar un token
</a>
</div>
</div>
</div>
);
}
// When connected (we have token), show the StreamYard-like portal
return (
<StudioPortal
serverUrl={credentials.serverUrl}
token={credentials.token}
roomName={credentials.roomName}
onRoomConnected={handleRoomConnected}
onRoomDisconnected={handleRoomDisconnected}
room={roomRef.current || undefined}
/>
);
}
export default App;