- Add Next.js app structure with base configs, linting, and formatting - Implement LiveKit Meet page, types, and utility functions - Add Docker, Compose, and deployment scripts for backend and token server - Provide E2E and smoke test scaffolding with Puppeteer and Playwright helpers - Include CSS modules and global styles for UI - Add postMessage and studio integration utilities - Update package.json with dependencies and scripts for development and testing
163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react'
|
|
import { useStudioSession } from '../hooks/useStudioSession'
|
|
import { connect, createLocalTracks, Room, LocalTrack } from 'livekit-client'
|
|
|
|
export const StudioConnector: React.FC = () => {
|
|
const { state, session, error, connect, disconnect } = useStudioSession()
|
|
|
|
const [room, setRoom] = useState<Room | null>(null)
|
|
const [localTracks, setLocalTracks] = useState<LocalTrack[] | null>(null)
|
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
|
const [connectingError, setConnectingError] = useState<string | null>(null)
|
|
|
|
// Determine LiveKit server URL (from Vite env or fallback)
|
|
const LIVEKIT_URL = (import.meta.env.VITE_LIVEKIT_URL as string) || (window as any).VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
|
|
|
|
useEffect(() => {
|
|
// When the hook reports connected state and we have a token, establish the livekit Room
|
|
const doConnect = async () => {
|
|
setConnectingError(null)
|
|
if (!session?.token) return
|
|
// If we already have a room, skip
|
|
if (room) return
|
|
|
|
try {
|
|
// Request local media permissions and create tracks
|
|
const tracks = await createLocalTracks({ audio: true, video: true })
|
|
setLocalTracks(tracks)
|
|
|
|
// Connect to LiveKit room using token (session.token) and LIVEKIT_URL
|
|
const r = await connect(LIVEKIT_URL, session.token, { reconnect: true })
|
|
|
|
// Publish local tracks
|
|
for (const t of tracks) {
|
|
try {
|
|
await r.localParticipant.publishTrack(t)
|
|
} catch (err) {
|
|
console.warn('publishTrack failed', err)
|
|
}
|
|
}
|
|
|
|
// Attach the first video track to our preview element
|
|
const videoTrack = tracks.find((t) => t.kind === 'video') as LocalTrack | undefined
|
|
if (videoTrack && videoRef.current) {
|
|
try {
|
|
const el = videoTrack.attach()
|
|
// attach returns HTMLMediaElement, ensure it's a video element
|
|
// Replace container's children with this element
|
|
if (videoRef.current.parentElement) {
|
|
const parent = videoRef.current.parentElement
|
|
parent.replaceChild(el, videoRef.current)
|
|
videoRef.current = el as HTMLVideoElement
|
|
}
|
|
} catch (err) {
|
|
console.warn('attach track failed', err)
|
|
}
|
|
}
|
|
|
|
// Listen for room events (optional)
|
|
r.on('disconnected', () => {
|
|
setRoom(null)
|
|
})
|
|
r.on('reconnecting', () => {
|
|
// Could set state to reconnecting
|
|
})
|
|
|
|
setRoom(r)
|
|
} catch (e: any) {
|
|
console.error('LiveKit connect error', e)
|
|
setConnectingError(String(e?.message ?? e))
|
|
}
|
|
}
|
|
|
|
if (state === 'connected' && session?.token) {
|
|
void doConnect()
|
|
}
|
|
|
|
return () => {
|
|
// nothing here: cleanup handled separately
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state, session?.token])
|
|
|
|
// Cleanup on unmount or disconnect
|
|
useEffect(() => {
|
|
return () => {
|
|
// Stop local tracks and disconnect room
|
|
try {
|
|
if (localTracks) {
|
|
for (const t of localTracks) {
|
|
try { t.stop(); t.detach(); } catch (e) { }
|
|
}
|
|
}
|
|
if (room) {
|
|
try { room.disconnect(); } catch (e) { }
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}, [localTracks, room])
|
|
|
|
const onCreateAndEnter = async () => {
|
|
try {
|
|
await connect({ createIfMissing: true, createPayload: { title: 'E2E Transmisión' } })
|
|
} catch (e) {
|
|
console.error('connect failed', e)
|
|
}
|
|
}
|
|
|
|
const onEnterExisting = async () => {
|
|
if (!session?.id) {
|
|
await onCreateAndEnter()
|
|
return
|
|
}
|
|
try {
|
|
await connect({ sessionId: session.id })
|
|
} catch (e) {
|
|
console.error('connect failed', e)
|
|
}
|
|
}
|
|
|
|
const onDisconnect = async () => {
|
|
try {
|
|
if (room) {
|
|
try { room.disconnect() } catch (e) { }
|
|
setRoom(null)
|
|
}
|
|
if (localTracks) {
|
|
for (const t of localTracks) {
|
|
try { t.stop(); t.detach(); } catch (e) { }
|
|
}
|
|
setLocalTracks(null)
|
|
}
|
|
await disconnect()
|
|
} catch (e) {
|
|
console.warn('disconnect error', e)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="studio-connector">
|
|
<div className="status" data-testid="studio-status">Estado: {state}</div>
|
|
{error && <div className="error" data-testid="studio-error">Error: {error}</div>}
|
|
{connectingError && <div className="error" data-testid="studio-connecting-error">Conexión LiveKit: {connectingError}</div>}
|
|
|
|
<div className="controls">
|
|
<button data-testid="btn-create-enter" onClick={onCreateAndEnter}>Crear transmisión y Entrar al Estudio</button>
|
|
<button data-testid="btn-enter" onClick={onEnterExisting}>Entrar al Estudio</button>
|
|
<button data-testid="btn-disconnect" onClick={onDisconnect}>Salir</button>
|
|
</div>
|
|
|
|
<div className="preview">
|
|
<p>Session: {session?.id ?? 'n/a'}</p>
|
|
<p>Token: {session?.token ? `${session.token.substring(0,20)}...` : 'n/a'}</p>
|
|
<div style={{ width: 320, height: 240, background: '#111' }}>
|
|
{/* placeholder video element - will be replaced by attach() result when track attaches */}
|
|
<video ref={videoRef} autoPlay muted playsInline style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default StudioConnector
|