Cesar Mendivil 8b458a3ddf feat: add initial LiveKit Meet integration with utility scripts, configs, and core components
- 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
2025-11-20 12:50:38 -07:00

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