feat: Implement main Studio component with user authentication and connection handling
feat: Create BroadcastStudio component as the main UI container for broadcasting feat: Develop ControlPanel component for managing broadcast controls and layouts feat: Add LiveKitBroadcastWrapper to encapsulate LiveKitRoom and manage broadcasting feat: Implement StreamView component for rendering video output with overlays and layouts feat: Create SceneContext for managing scene configurations and layouts chore: Update index exports for broadcast components
This commit is contained in:
parent
3ed1ac7cc2
commit
78e83b46dd
234
packages/studio-panel/docs/BROADCAST_STUDIO_UI.md
Normal file
234
packages/studio-panel/docs/BROADCAST_STUDIO_UI.md
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
# LiveKit Broadcast Studio UI - Documentación
|
||||||
|
|
||||||
|
## 📋 Descripción General
|
||||||
|
|
||||||
|
Sistema de interfaz de usuario para un estudio de producción de video en vivo estilo StreamYard, construido con React, TypeScript y LiveKit Components.
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura
|
||||||
|
|
||||||
|
### Estructura de Componentes
|
||||||
|
|
||||||
|
```
|
||||||
|
BroadcastStudio (Contenedor Principal)
|
||||||
|
├── SceneProvider (Context)
|
||||||
|
│ ├── StreamView (Visualización - CONSUMIDOR)
|
||||||
|
│ │ ├── Layouts dinámicos basados en sceneConfig
|
||||||
|
│ │ ├── Renderizado de participantes (LiveKit)
|
||||||
|
│ │ └── Overlays (logos, lower thirds)
|
||||||
|
│ │
|
||||||
|
│ └── ControlPanel (Controles - MODIFICADOR)
|
||||||
|
│ ├── LocalControls (Izquierda - Vista local + Presentar)
|
||||||
|
│ ├── ScrollableLayoutsContainer (Centro - Botones de layouts)
|
||||||
|
│ └── ActionControls (Derecha - Config y recursos)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sistema de Estado (SceneContext)
|
||||||
|
|
||||||
|
**Ubicación:** `src/context/SceneContext.tsx`
|
||||||
|
|
||||||
|
El contexto centralizado gestiona toda la configuración de escenas:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SceneConfig {
|
||||||
|
participantLayout: ParticipantLayoutType // Tipo de layout activo
|
||||||
|
mediaSource: MediaSourceType | null // Contenido adicional (screen, file, etc)
|
||||||
|
overlays: OverlayConfig // Logos, lower thirds, etc
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regla de oro:**
|
||||||
|
- **ControlPanel**: Único componente que MODIFICA `sceneConfig`
|
||||||
|
- **StreamView**: Único componente que CONSUME `sceneConfig` para renderizar
|
||||||
|
|
||||||
|
## 🎨 Componentes Principales
|
||||||
|
|
||||||
|
### 1. StreamView
|
||||||
|
**Archivo:** `src/components/broadcast/StreamView.tsx`
|
||||||
|
|
||||||
|
Renderiza la salida final de video en formato 16:9.
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- Aspecto ratio fijo 16:9 (`aspect-ratio: 16 / 9`)
|
||||||
|
- 6 layouts predefinidos:
|
||||||
|
- `grid_4`: Grid 2×2
|
||||||
|
- `grid_6`: Grid 3×2
|
||||||
|
- `focus_side`: Foco principal + sidebar
|
||||||
|
- `side_by_side`: Dos participantes lado a lado
|
||||||
|
- `presentation`: Pantalla compartida + speaker pequeño
|
||||||
|
- `single_speaker`: Un solo participante
|
||||||
|
- Sistema de overlays configurable
|
||||||
|
- Integración con hooks de LiveKit (`useParticipants`, `useTracks`)
|
||||||
|
|
||||||
|
### 2. ControlPanel
|
||||||
|
**Archivo:** `src/components/broadcast/ControlPanel.tsx`
|
||||||
|
|
||||||
|
Panel de control interactivo dividido en 3 secciones.
|
||||||
|
|
||||||
|
**Estructura CSS:**
|
||||||
|
```css
|
||||||
|
.control-panel-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.1 LocalControls (Izquierda)
|
||||||
|
- Vista previa del usuario local
|
||||||
|
- Botón "Presentar"
|
||||||
|
- `flex-shrink: 0` (ancho fijo)
|
||||||
|
|
||||||
|
#### 2.2 ScrollableLayoutsContainer (Centro)
|
||||||
|
- Scroll horizontal de botones de layouts
|
||||||
|
- `flex-grow: 1; overflow-x: auto; overflow-y: hidden`
|
||||||
|
- Botones con `flex-shrink: 0` en fila única
|
||||||
|
|
||||||
|
#### 2.3 ActionControls (Derecha)
|
||||||
|
- Botones de acción (Editor, Config, Añadir)
|
||||||
|
- `flex-shrink: 0` (ancho fijo)
|
||||||
|
|
||||||
|
### 3. BroadcastStudio
|
||||||
|
**Archivo:** `src/components/broadcast/BroadcastStudio.tsx`
|
||||||
|
|
||||||
|
Contenedor principal que alinea todo.
|
||||||
|
|
||||||
|
**CSS clave:**
|
||||||
|
```css
|
||||||
|
.main-app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-view-container,
|
||||||
|
.control-panel-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px; /* Mismo ancho máximo para ambos */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. LiveKitBroadcastWrapper
|
||||||
|
**Archivo:** `src/components/broadcast/LiveKitBroadcastWrapper.tsx`
|
||||||
|
|
||||||
|
Wrapper que conecta el BroadcastStudio con LiveKit.
|
||||||
|
|
||||||
|
## 🔧 Uso
|
||||||
|
|
||||||
|
### Integración Básica
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { LiveKitBroadcastWrapper } from './components/broadcast'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<LiveKitBroadcastWrapper
|
||||||
|
token="your-livekit-token"
|
||||||
|
serverUrl="wss://your-server.com"
|
||||||
|
userName="Usuario"
|
||||||
|
roomName="sala-demo"
|
||||||
|
onDisconnect={() => console.log('Desconectado')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uso Standalone (sin LiveKit)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { BroadcastStudio } from './components/broadcast'
|
||||||
|
import { LiveKitRoom } from '@livekit/components-react'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<LiveKitRoom token={token} serverUrl={serverUrl}>
|
||||||
|
<BroadcastStudio />
|
||||||
|
</LiveKitRoom>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Acceso al Contexto de Escenas
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useScene } from './context/SceneContext'
|
||||||
|
|
||||||
|
function MiComponente() {
|
||||||
|
const { sceneConfig, applyPreset, updateOverlays } = useScene()
|
||||||
|
|
||||||
|
// Aplicar un preset
|
||||||
|
const handleChangeLayout = () => {
|
||||||
|
applyPreset('FOCUS_SIDE')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar overlays
|
||||||
|
const handleToggleLogo = () => {
|
||||||
|
updateOverlays({ showLogo: !sceneConfig.overlays.showLogo })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Presets de Layouts
|
||||||
|
|
||||||
|
Definidos en `SceneContext.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
PRESET_LAYOUTS = {
|
||||||
|
GRID_4: { participantLayout: 'grid_4', ... },
|
||||||
|
GRID_6: { participantLayout: 'grid_6', ... },
|
||||||
|
FOCUS_SIDE: { participantLayout: 'focus_side', ... },
|
||||||
|
SIDE_BY_SIDE: { participantLayout: 'side_by_side', ... },
|
||||||
|
PRESENTATION: { participantLayout: 'presentation', ... },
|
||||||
|
SINGLE_SPEAKER: { participantLayout: 'single_speaker', ... },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Próximos Pasos
|
||||||
|
|
||||||
|
### Features Pendientes
|
||||||
|
- [ ] Integración con LiveKit Egress para grabación/streaming
|
||||||
|
- [ ] Editor visual de escenas (modal con drag & drop)
|
||||||
|
- [ ] Gestión de overlays personalizado
|
||||||
|
- [ ] Soporte para múltiples cámaras por participante
|
||||||
|
- [ ] Transiciones animadas entre layouts
|
||||||
|
- [ ] Guardado/carga de escenas personalizadas
|
||||||
|
|
||||||
|
### Mejoras de UX
|
||||||
|
- [ ] Tooltips informativos en botones de layout
|
||||||
|
- [ ] Preview en miniatura de cada layout
|
||||||
|
- [ ] Keyboard shortcuts para cambio rápido
|
||||||
|
- [ ] Indicador visual del layout activo más prominente
|
||||||
|
- [ ] Confirmación antes de cambios críticos
|
||||||
|
|
||||||
|
## 🐛 Debugging
|
||||||
|
|
||||||
|
### Verificar estado de escenas
|
||||||
|
```tsx
|
||||||
|
// En DevTools Console:
|
||||||
|
window.__SCENE_DEBUG__ = true
|
||||||
|
|
||||||
|
// O agregar en tu componente:
|
||||||
|
console.log('[SceneDebug]', sceneConfig)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs de LiveKit
|
||||||
|
Los hooks de LiveKit (`useParticipants`, `useTracks`) ya incluyen logs internos.
|
||||||
|
Para más detalle, habilitar en LiveKitRoom:
|
||||||
|
```tsx
|
||||||
|
<LiveKitRoom logLevel="debug" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Notas de Implementación
|
||||||
|
|
||||||
|
1. **Aspecto Ratio:** StreamView usa `aspect-ratio: 16/9` nativo de CSS (compatibilidad moderna)
|
||||||
|
2. **Scroll Horizontal:** El scroll en LayoutsContainer es solo horizontal para mejor UX
|
||||||
|
3. **Flexibilidad:** Todos los componentes son modulares y pueden usarse independientemente
|
||||||
|
4. **Performance:** Los layouts se renderizan condicionalmente para evitar re-renders innecesarios
|
||||||
|
5. **TypeScript:** Todo el código está fuertemente tipado para mejor DX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Creado el:** 7 de noviembre de 2025
|
||||||
|
**Versión:** 1.0.0
|
||||||
|
**Stack:** React + TypeScript + LiveKit + Tailwind CSS
|
||||||
36
packages/studio-panel/docs/INTEGRATION_EXAMPLE.tsx
Normal file
36
packages/studio-panel/docs/INTEGRATION_EXAMPLE.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// Ejemplo de cómo integrar BroadcastStudio en Studio.tsx existente
|
||||||
|
|
||||||
|
import { LiveKitBroadcastWrapper } from './broadcast'
|
||||||
|
|
||||||
|
// Reemplazar la sección de render actual con:
|
||||||
|
|
||||||
|
// Opción 1: Reemplazar completamente StudioVideoArea con BroadcastStudio
|
||||||
|
{!isDemoMode && token && serverUrl && (
|
||||||
|
<LiveKitBroadcastWrapper
|
||||||
|
token={token}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
userName={userName}
|
||||||
|
roomName={roomName}
|
||||||
|
onDisconnect={() => {
|
||||||
|
console.log('Desconectado del estudio')
|
||||||
|
setIsDemoMode(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
// Opción 2: Usar solo dentro del LiveKitRoom existente
|
||||||
|
<LiveKitRoom token={token} serverUrl={serverUrl} ...>
|
||||||
|
<BroadcastStudio />
|
||||||
|
</LiveKitRoom>
|
||||||
|
|
||||||
|
// Opción 3: Modo híbrido - Toggle entre vista clásica y BroadcastStudio
|
||||||
|
const [useBroadcastUI, setUseBroadcastUI] = useState(false)
|
||||||
|
|
||||||
|
{useBroadcastUI ? (
|
||||||
|
<BroadcastStudio />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StudioVideoArea ... />
|
||||||
|
<StudioControls ... />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
File diff suppressed because it is too large
Load Diff
789
packages/studio-panel/src/components/Studio.tsx.backup
Normal file
789
packages/studio-panel/src/components/Studio.tsx.backup
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { LiveKitRoom } from '@livekit/components-react'
|
||||||
|
import '@livekit/components-styles'
|
||||||
|
import StudioHeader from './StudioHeader'
|
||||||
|
import StudioLeftSidebar from './StudioLeftSidebar'
|
||||||
|
import StudioRightPanel, { TabsColumn, TabType } from './StudioRightPanel'
|
||||||
|
import { SceneProvider } from '../context/SceneContext'
|
||||||
|
import StreamView from './broadcast/StreamView'
|
||||||
|
import ControlPanel from './broadcast/ControlPanel'
|
||||||
|
|
||||||
|
function Studio() {
|
||||||
|
const [token, setToken] = useState<string>('')
|
||||||
|
const [serverUrl, setServerUrl] = useState<string>('wss://avanzacast-test-0kl2kzjr.livekit.cloud')
|
||||||
|
const [roomName, setRoomName] = useState<string>('')
|
||||||
|
const [userName, setUserName] = useState<string>('')
|
||||||
|
const [showLeftPanel, setShowLeftPanel] = useState(true)
|
||||||
|
const [showRightPanel, setShowRightPanel] = useState(true)
|
||||||
|
const [activeRightTab, setActiveRightTab] = useState<TabType>('chat')
|
||||||
|
const [needsUserName, setNeedsUserName] = useState(false)
|
||||||
|
const [inputUserName, setInputUserName] = useState('')
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const urlToken = params.get('token')
|
||||||
|
const urlRoom = params.get('room')
|
||||||
|
const urlUser = params.get('user')
|
||||||
|
const savedToken = urlToken || localStorage.getItem('avanzacast_studio_token') || ''
|
||||||
|
const savedRoom = urlRoom || localStorage.getItem('avanzacast_studio_room') || ''
|
||||||
|
const savedServerUrl = localStorage.getItem('avanzacast_studio_serverUrl') || 'wss://avanzacast-test-0kl2kzjr.livekit.cloud'
|
||||||
|
const savedUserName = urlUser || localStorage.getItem('avanzacast_studio_userName') || ''
|
||||||
|
setToken(savedToken)
|
||||||
|
setRoomName(savedRoom)
|
||||||
|
setServerUrl(savedServerUrl)
|
||||||
|
if (savedUserName) {
|
||||||
|
setUserName(savedUserName)
|
||||||
|
setNeedsUserName(false)
|
||||||
|
} else if (savedToken && savedRoom) {
|
||||||
|
setNeedsUserName(true)
|
||||||
|
}
|
||||||
|
if (savedToken) localStorage.setItem('avanzacast_studio_token', savedToken)
|
||||||
|
if (savedRoom) localStorage.setItem('avanzacast_studio_room', savedRoom)
|
||||||
|
if (savedServerUrl) localStorage.setItem('avanzacast_studio_serverUrl', savedServerUrl)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmitUserName = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (inputUserName.trim()) {
|
||||||
|
const finalUserName = inputUserName.trim()
|
||||||
|
setUserName(finalUserName)
|
||||||
|
localStorage.setItem('avanzacast_studio_userName', finalUserName)
|
||||||
|
setNeedsUserName(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsUserName) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900">
|
||||||
|
<div className="w-full max-w-md mx-4">
|
||||||
|
<div className="bg-gray-800/50 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-700/50 p-8">
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-pink-500 to-purple-600 rounded-lg flex items-center justify-center shadow-lg">
|
||||||
|
<span className="text-white text-xl font-bold">A</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-white">AvanzaCast</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-white text-center mb-2">Bienvenido al Estudio</h2>
|
||||||
|
<p className="text-gray-400 text-center mb-6">Ingresa tu nombre para unirte a la transmisión</p>
|
||||||
|
<form onSubmit={handleSubmitUserName} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="userName" className="block text-sm font-medium text-gray-300 mb-2">Nombre de usuario</label>
|
||||||
|
<input id="userName" type="text" value={inputUserName} onChange={(e) => setInputUserName(e.target.value)} placeholder="Tu nombre" className="w-full px-4 py-3 bg-gray-900/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all" autoFocus required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="w-full py-3 bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transform hover:scale-[1.02] transition-all duration-200">Entrar al Estudio</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnecting) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4"><div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto"></div></div>
|
||||||
|
<div className="text-white text-xl">Conectando al estudio...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !roomName) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900">
|
||||||
|
<div className="text-center max-w-md mx-4">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-pink-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg mx-auto mb-4">
|
||||||
|
<span className="text-white text-2xl font-bold">A</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">AvanzaCast Studio</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-lg mb-4">No hay datos de conexión disponibles.</p>
|
||||||
|
<p className="text-gray-500 text-sm">Para acceder al estudio, debes iniciar una transmisión desde el panel de broadcast.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LiveKitRoom token={token} serverUrl={serverUrl} onDisconnected={() => console.log('[LiveKit] Desconectado.')} onError={(e) => console.error('[LiveKit] Error:', e)} data-lk-theme="default" className="studio-container">
|
||||||
|
<SceneProvider>
|
||||||
|
<div className="flex flex-col h-screen bg-gray-900 overflow-hidden">
|
||||||
|
<div className="flex-none"><StudioHeader roomName={roomName} userName={userName} /></div>
|
||||||
|
<div className="flex-1 overflow-hidden relative min-h-0">
|
||||||
|
<div className={`absolute left-0 top-0 bottom-0 z-20 transition-transform duration-300 ${showLeftPanel ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||||
|
<StudioLeftSidebar />
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowLeftPanel(!showLeftPanel)} className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300" style={{ left: showLeftPanel ? '256px' : '0px' }}>
|
||||||
|
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" className="hover:opacity-80">
|
||||||
|
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
|
||||||
|
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
|
||||||
|
<path d={showLeftPanel ? "M6 44L10 50.5L6 57" : "M10 44L6 50.5L10 57"} stroke="#FFF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="absolute top-0 bottom-0 transition-all duration-300 flex flex-col overflow-hidden" style={{ left: showLeftPanel ? '256px' : '0px', right: showRightPanel ? '400px' : '80px' }}>
|
||||||
|
<div className="flex-1 flex items-center justify-center px-4 pt-4 overflow-hidden min-h-0">
|
||||||
|
<div className="w-full h-full max-w-6xl flex items-center justify-center">
|
||||||
|
<div className="w-full relative">
|
||||||
|
<div className="absolute top-3 right-3 z-10 flex items-center gap-2 text-gray-400 text-xs bg-gray-900/80 backdrop-blur-sm px-3 py-1.5 rounded-lg">
|
||||||
|
<span>Producido con</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-5 h-5 bg-gradient-to-br from-pink-500 to-purple-600 rounded flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs font-bold">A</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-white">AvanzaCast</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StreamView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-none px-4 pb-4"><ControlPanel /></div>
|
||||||
|
</div>
|
||||||
|
<div className={`absolute right-0 top-0 bottom-0 z-20 transition-transform duration-300 ${showRightPanel ? 'translate-x-0' : 'translate-x-[320px]'}`}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
<TabsColumn activeTab={activeRightTab} onChangeTab={(t: TabType) => setActiveRightTab(t)} />
|
||||||
|
<StudioRightPanel activeTab={activeRightTab} onChangeTab={(t: TabType) => setActiveRightTab(t)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowRightPanel(!showRightPanel)} className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300" style={{ right: showRightPanel ? '400px' : '0px' }}>
|
||||||
|
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" className="hover:opacity-80 rotate-180">
|
||||||
|
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
|
||||||
|
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
|
||||||
|
<path d={showRightPanel ? "M10 44L6 50.5L10 57" : "M6 44L10 50.5L10 57"} stroke="#FFF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SceneProvider>
|
||||||
|
</LiveKitRoom>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Studio
|
||||||
@ -1,217 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useLocalParticipant, useTracks } from '@livekit/components-react'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import {
|
|
||||||
MdMic,
|
|
||||||
MdMicOff,
|
|
||||||
MdVideocam,
|
|
||||||
MdVideocamOff,
|
|
||||||
MdScreenShare,
|
|
||||||
MdStopScreenShare,
|
|
||||||
MdSettings,
|
|
||||||
MdPeople,
|
|
||||||
MdViewComfy,
|
|
||||||
MdFiberManualRecord,
|
|
||||||
MdStop,
|
|
||||||
} from 'react-icons/md'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onOpenPresentation?: () => void
|
|
||||||
onShareScreen?: () => void
|
|
||||||
sharedPresentation?: { type: 'screen' | 'file', url: string } | null
|
|
||||||
onClearPresentation?: () => void
|
|
||||||
layout?: 'grid' | 'focus'
|
|
||||||
mode?: 'video' | 'audio'
|
|
||||||
onChangeLayout?: (layout: 'grid'|'focus') => void
|
|
||||||
onChangeMode?: (mode: 'video'|'audio') => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const StudioControls: React.FC<Props> = ({ onOpenPresentation, onShareScreen, sharedPresentation, onClearPresentation, layout, mode, onChangeLayout, onChangeMode }) => {
|
|
||||||
const { localParticipant } = useLocalParticipant()
|
|
||||||
const [micEnabled, setMicEnabled] = useState(true)
|
|
||||||
const [cameraEnabled, setCameraEnabled] = useState(true)
|
|
||||||
const [isScreenSharing, setIsScreenSharing] = useState(false)
|
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
|
||||||
|
|
||||||
// Sincronizar estado con LiveKit
|
|
||||||
useEffect(() => {
|
|
||||||
if (localParticipant) {
|
|
||||||
setMicEnabled(localParticipant.isMicrophoneEnabled)
|
|
||||||
setCameraEnabled(localParticipant.isCameraEnabled)
|
|
||||||
setIsScreenSharing(localParticipant.isScreenShareEnabled)
|
|
||||||
}
|
|
||||||
}, [localParticipant])
|
|
||||||
|
|
||||||
const toggleMicrophone = async () => {
|
|
||||||
if (localParticipant) {
|
|
||||||
const enabled = !micEnabled
|
|
||||||
await localParticipant.setMicrophoneEnabled(enabled)
|
|
||||||
setMicEnabled(enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleCamera = async () => {
|
|
||||||
if (localParticipant) {
|
|
||||||
const enabled = !cameraEnabled
|
|
||||||
await localParticipant.setCameraEnabled(enabled)
|
|
||||||
setCameraEnabled(enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleScreenShare = async () => {
|
|
||||||
if (localParticipant) {
|
|
||||||
const enabled = !isScreenSharing
|
|
||||||
await localParticipant.setScreenShareEnabled(enabled)
|
|
||||||
setIsScreenSharing(enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleRecording = () => {
|
|
||||||
// TODO: Implementar grabación con LiveKit
|
|
||||||
setIsRecording(!isRecording)
|
|
||||||
console.log('Recording:', !isRecording)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="studio-controls bg-gray-800 border-t border-gray-700 px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between max-w-7xl mx-auto">
|
|
||||||
{/* Izquierda - Info de sala */}
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<span className="text-sm text-gray-300 font-medium">
|
|
||||||
Transmisión en vivo
|
|
||||||
</span>
|
|
||||||
{isRecording && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
|
||||||
<span className="text-sm text-red-400">Grabando</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Centro - Controles principales */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{/* Micrófono */}
|
|
||||||
<button
|
|
||||||
onClick={toggleMicrophone}
|
|
||||||
className={`control-button ${
|
|
||||||
!micEnabled ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'
|
|
||||||
} p-3 rounded-lg transition-colors`}
|
|
||||||
title={micEnabled ? 'Desactivar micrófono' : 'Activar micrófono'}
|
|
||||||
>
|
|
||||||
{micEnabled ? <MdMic size={24} /> : <MdMicOff size={24} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Cámara */}
|
|
||||||
<button
|
|
||||||
onClick={toggleCamera}
|
|
||||||
className={`control-button ${
|
|
||||||
!cameraEnabled ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'
|
|
||||||
} p-3 rounded-lg transition-colors`}
|
|
||||||
title={cameraEnabled ? 'Desactivar cámara' : 'Activar cámara'}
|
|
||||||
>
|
|
||||||
{cameraEnabled ? <MdVideocam size={24} /> : <MdVideocamOff size={24} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Compartir pantalla */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
// Preferimos delegar a un handler externo (que abrirá el panel o iniciará screen share)
|
|
||||||
if (onOpenPresentation) return onOpenPresentation()
|
|
||||||
if (onShareScreen) return onShareScreen()
|
|
||||||
toggleScreenShare()
|
|
||||||
}}
|
|
||||||
className={`control-button ${
|
|
||||||
isScreenSharing ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-700 hover:bg-gray-600'
|
|
||||||
} p-3 rounded-lg transition-colors`}
|
|
||||||
title={isScreenSharing ? 'Dejar de compartir' : 'Compartir pantalla'}
|
|
||||||
>
|
|
||||||
{isScreenSharing ? <MdStopScreenShare size={24} /> : <MdScreenShare size={24} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Botón rápido para abrir el panel de presentación (subir archivos) */}
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenPresentation && onOpenPresentation()}
|
|
||||||
className="control-button bg-green-600 hover:bg-green-700 p-3 rounded-lg transition-colors"
|
|
||||||
title="Compartir presentación"
|
|
||||||
>
|
|
||||||
<MdPeople size={20} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-px h-8 bg-gray-600 mx-2"></div>
|
|
||||||
|
|
||||||
{/* Layouts */}
|
|
||||||
<button
|
|
||||||
className="control-button bg-gray-700 hover:bg-gray-600 p-3 rounded-lg transition-colors"
|
|
||||||
title="Cambiar diseño"
|
|
||||||
>
|
|
||||||
<MdViewComfy size={24} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Layout selector */}
|
|
||||||
<div className="ml-2 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onChangeLayout && onChangeLayout('grid')}
|
|
||||||
className={`px-2 py-1 rounded ${layout === 'grid' ? 'bg-pink-600' : 'bg-gray-700'}`}
|
|
||||||
title="Grid layout"
|
|
||||||
>
|
|
||||||
Grid
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onChangeLayout && onChangeLayout('focus')}
|
|
||||||
className={`px-2 py-1 rounded ${layout === 'focus' ? 'bg-pink-600' : 'bg-gray-700'}`}
|
|
||||||
title="Focus layout"
|
|
||||||
>
|
|
||||||
Focus
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button onClick={() => onChangeMode && onChangeMode('video')} className={`px-2 py-1 rounded ${mode === 'video' ? 'bg-pink-600' : 'bg-gray-700'}`}>Video</button>
|
|
||||||
<button onClick={() => onChangeMode && onChangeMode('audio')} className={`px-2 py-1 rounded ${mode === 'audio' ? 'bg-pink-600' : 'bg-gray-700'}`}>Audio</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Configuración */}
|
|
||||||
<button
|
|
||||||
className="control-button bg-gray-700 hover:bg-gray-600 p-3 rounded-lg transition-colors"
|
|
||||||
title="Configuración"
|
|
||||||
>
|
|
||||||
<MdSettings size={24} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-px h-8 bg-gray-600 mx-2"></div>
|
|
||||||
|
|
||||||
{/* Grabar */}
|
|
||||||
<button
|
|
||||||
onClick={toggleRecording}
|
|
||||||
className={`control-button ${
|
|
||||||
isRecording ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'
|
|
||||||
} p-3 rounded-lg transition-colors`}
|
|
||||||
title={isRecording ? 'Detener grabación' : 'Grabar'}
|
|
||||||
>
|
|
||||||
{isRecording ? <MdStop size={24} /> : <MdFiberManualRecord size={24} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Derecha - Presentación / Salir */}
|
|
||||||
<div>
|
|
||||||
{sharedPresentation ? (
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-sm text-green-300">Presentación activa</span>
|
|
||||||
<button onClick={() => onClearPresentation && onClearPresentation()} className="px-3 py-1 bg-gray-700 text-white rounded">Detener</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm('¿Estás seguro de que quieres salir del estudio?')) {
|
|
||||||
window.location.href = '/'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Salir
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StudioControls
|
|
||||||
@ -1,103 +1,36 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { MdNotifications, MdSettings, MdExitToApp } from 'react-icons/md'
|
|
||||||
|
|
||||||
interface StudioHeaderProps {
|
interface StudioHeaderProps {
|
||||||
roomName: string
|
roomName: string
|
||||||
userName: string
|
userName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const StudioHeader: React.FC<StudioHeaderProps> = ({ roomName, userName }) => {
|
function StudioHeader({ roomName, userName }: StudioHeaderProps) {
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false)
|
|
||||||
const [isLive, setIsLive] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-gray-800 border-b border-gray-700 px-4 py-2 flex items-center justify-between">
|
<div className="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-4">
|
||||||
{/* Logo e Info */}
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-pink-500 to-purple-600 rounded flex items-center justify-center">
|
||||||
|
<span className="text-white text-sm font-bold">A</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white font-semibold text-sm">AvanzaCast Studio</h1>
|
||||||
|
{roomName && <p className="text-gray-400 text-xs">Sala: {roomName}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-white text-sm font-medium">{userName}</p>
|
||||||
|
<p className="text-gray-400 text-xs">Transmitiendo</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-10 h-10 bg-gradient-to-br from-pink-500 to-red-500 rounded-lg flex items-center justify-center">
|
<button className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white text-sm rounded transition-colors">
|
||||||
<span className="text-white font-bold text-lg">A</span>
|
Configuración
|
||||||
</div>
|
</button>
|
||||||
<div>
|
<button className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded transition-colors">
|
||||||
<h1 className="text-base font-bold text-white">
|
Salir
|
||||||
Avanza<span className="font-extrabold">Cast</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-xs text-gray-400">Studio</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Badge BETA */}
|
|
||||||
<span className="bg-pink-500/20 text-pink-400 text-xs font-semibold px-2 py-0.5 rounded">
|
|
||||||
BETA
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Título de transmisión y estado */}
|
|
||||||
<div className="flex-1 text-center">
|
|
||||||
<h2 className="text-sm font-medium text-gray-300">{roomName}</h2>
|
|
||||||
{isLive && (
|
|
||||||
<div className="flex items-center justify-center gap-2 mt-1">
|
|
||||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
|
|
||||||
<span className="text-xs text-red-400 font-semibold">EN VIVO</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Acciones y usuario */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Botón Go Live */}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsLive(!isLive)}
|
|
||||||
className={`px-4 py-1.5 rounded-lg font-semibold text-sm transition-all transform hover:scale-105 ${
|
|
||||||
isLive
|
|
||||||
? 'bg-red-500 hover:bg-red-600 text-white'
|
|
||||||
: 'bg-gradient-to-r from-pink-500 to-red-500 hover:from-pink-600 hover:to-red-600 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isLive ? 'Detener' : 'Salir en Vivo'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Notificaciones */}
|
|
||||||
<button className="p-2 hover:bg-gray-700 rounded-lg transition-colors relative">
|
|
||||||
<MdNotifications size={20} className="text-gray-300" />
|
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-pink-500 rounded-full"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Usuario */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
|
||||||
className="flex items-center gap-2 hover:bg-gray-700 px-3 py-1.5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-white text-sm font-semibold">
|
|
||||||
{userName.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-300">{userName}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Menu dropdown */}
|
|
||||||
{showUserMenu && (
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-48 bg-gray-700 rounded-lg shadow-lg border border-gray-600 py-1 z-50">
|
|
||||||
<button className="w-full px-4 py-2 text-left text-sm text-gray-300 hover:bg-gray-600 flex items-center gap-2">
|
|
||||||
<MdSettings size={16} />
|
|
||||||
Configuración
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-gray-600 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<MdExitToApp size={16} />
|
|
||||||
Salir del Studio
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default StudioHeader
|
export default StudioHeader
|
||||||
|
|
||||||
|
|||||||
@ -1,176 +1,43 @@
|
|||||||
import { useState } from 'react'
|
function StudioLeftSidebar() {
|
||||||
import { MdAdd, MdMoreVert, MdEdit, MdDelete, MdContentCopy } from 'react-icons/md'
|
|
||||||
import { DEMO_SCENES } from '../config/demo'
|
|
||||||
|
|
||||||
interface Scene {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
thumbnail: string
|
|
||||||
active: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const StudioLeftSidebar = () => {
|
|
||||||
const [scenes, setScenes] = useState<Scene[]>(DEMO_SCENES)
|
|
||||||
|
|
||||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleSceneClick = (sceneId: string) => {
|
|
||||||
setScenes(scenes.map(scene => ({
|
|
||||||
...scene,
|
|
||||||
active: scene.id === sceneId
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddScene = () => {
|
|
||||||
const newScene: Scene = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
name: `Nueva Escena ${scenes.length + 1}`,
|
|
||||||
thumbnail: '/placeholder-scene.jpg',
|
|
||||||
active: false
|
|
||||||
}
|
|
||||||
setScenes([...scenes, newScene])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteScene = (sceneId: string) => {
|
|
||||||
if (scenes.length > 1) {
|
|
||||||
setScenes(scenes.filter(scene => scene.id !== sceneId))
|
|
||||||
}
|
|
||||||
setOpenMenuId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDuplicateScene = (sceneId: string) => {
|
|
||||||
const sceneToDuplicate = scenes.find(scene => scene.id === sceneId)
|
|
||||||
if (sceneToDuplicate) {
|
|
||||||
const newScene: Scene = {
|
|
||||||
...sceneToDuplicate,
|
|
||||||
id: Date.now().toString(),
|
|
||||||
name: `${sceneToDuplicate.name} (copia)`,
|
|
||||||
active: false
|
|
||||||
}
|
|
||||||
setScenes([...scenes, newScene])
|
|
||||||
}
|
|
||||||
setOpenMenuId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 h-full bg-gray-800 border-r border-gray-700 flex flex-col">
|
<div className="w-64 h-full bg-gray-800 border-r border-gray-700 overflow-y-auto">
|
||||||
{/* Header de Escenas */}
|
<div className="p-4">
|
||||||
<div className="p-4 border-b border-gray-700">
|
<h3 className="text-white font-semibold mb-4">Participantes</h3>
|
||||||
<h3 className="text-white font-semibold text-sm flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="p-3 bg-gray-700 rounded hover:bg-gray-600 transition-colors">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
|
<div className="flex items-center gap-2">
|
||||||
</svg>
|
<div className="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center">
|
||||||
Escenas
|
<span className="text-white text-xs font-bold">TÚ</span>
|
||||||
</h3>
|
</div>
|
||||||
</div>
|
<div className="flex-1">
|
||||||
|
<p className="text-white text-sm font-medium">Tú (Anfitrión)</p>
|
||||||
{/* Lista de Escenas */}
|
<p className="text-gray-400 text-xs">En vivo</p>
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
|
||||||
{scenes.map((scene) => (
|
|
||||||
<div
|
|
||||||
key={scene.id}
|
|
||||||
className={`group relative flex items-center gap-3 p-2 rounded-lg mb-2 cursor-pointer transition-all ${
|
|
||||||
scene.active
|
|
||||||
? 'bg-pink-500/20 border border-pink-500/50'
|
|
||||||
: 'bg-gray-700/50 hover:bg-gray-700 border border-transparent'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleSceneClick(scene.id)}
|
|
||||||
>
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div className={`w-16 h-10 rounded overflow-hidden flex-shrink-0 ${
|
|
||||||
scene.active ? 'ring-2 ring-pink-500' : ''
|
|
||||||
}`}>
|
|
||||||
<div className="w-full h-full bg-gray-600 flex items-center justify-center">
|
|
||||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nombre */}
|
|
||||||
<span className={`flex-1 text-sm truncate ${
|
|
||||||
scene.active ? 'text-white font-medium' : 'text-gray-300'
|
|
||||||
}`}>
|
|
||||||
{scene.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Menú de opciones */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setOpenMenuId(openMenuId === scene.id ? null : scene.id)
|
|
||||||
}}
|
|
||||||
className="p-1 rounded hover:bg-gray-600 text-gray-400 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<MdMoreVert size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown menu */}
|
|
||||||
{openMenuId === scene.id && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 w-40 bg-gray-700 rounded-lg shadow-lg border border-gray-600 py-1 z-50">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDuplicateScene(scene.id)
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-left text-sm text-gray-300 hover:bg-gray-600 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<MdContentCopy size={16} />
|
|
||||||
Duplicar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
// TODO: Implementar edición
|
|
||||||
setOpenMenuId(null)
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-left text-sm text-gray-300 hover:bg-gray-600 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<MdEdit size={16} />
|
|
||||||
Renombrar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleDeleteScene(scene.id)
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-gray-600 flex items-center gap-2"
|
|
||||||
disabled={scenes.length === 1}
|
|
||||||
>
|
|
||||||
<MdDelete size={16} />
|
|
||||||
Eliminar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
{/* Botón agregar escena */}
|
|
||||||
<button
|
|
||||||
onClick={handleAddScene}
|
|
||||||
className="w-full flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed border-gray-600 text-gray-400 hover:border-pink-500 hover:text-pink-500 transition-all mt-2"
|
|
||||||
>
|
|
||||||
<MdAdd size={20} />
|
|
||||||
<span className="text-sm font-medium">Nueva Escena</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Acciones rápidas */}
|
<div className="p-4 border-t border-gray-700">
|
||||||
<div className="p-3 border-t border-gray-700 space-y-2">
|
<h3 className="text-white font-semibold mb-4">Fuentes</h3>
|
||||||
<button className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded-lg transition-colors">
|
<div className="space-y-2">
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button className="w-full p-3 bg-gray-700 hover:bg-gray-600 rounded text-left transition-colors">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<div className="flex items-center gap-2">
|
||||||
</svg>
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
Agregar Elemento
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
</button>
|
</svg>
|
||||||
<button className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded-lg transition-colors">
|
<span className="text-white text-sm">Cámara</span>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</div>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
</button>
|
||||||
</svg>
|
<button className="w-full p-3 bg-gray-700 hover:bg-gray-600 rounded text-left transition-colors">
|
||||||
Añadir Multimedia
|
<div className="flex items-center gap-2">
|
||||||
</button>
|
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-white text-sm">Pantalla</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,186 +0,0 @@
|
|||||||
import React, { useState } from 'react'
|
|
||||||
import { useParticipants } from '@livekit/components-react'
|
|
||||||
import { MdPeople, MdChat, MdNoteAdd, MdMoreVert, MdClose } from 'react-icons/md'
|
|
||||||
|
|
||||||
interface StudioSidebarProps {
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const StudioSidebar: React.FC<StudioSidebarProps> = ({ onClose }) => {
|
|
||||||
const [activeTab, setActiveTab] = useState<'people' | 'chat' | 'notes'>('people')
|
|
||||||
const participants = useParticipants()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="studio-sidebar w-80 bg-gray-800 border-l border-gray-700 flex flex-col">
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex border-b border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('people')}
|
|
||||||
className={`flex-1 flex items-center justify-center space-x-2 py-3 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === 'people'
|
|
||||||
? 'text-blue-400 border-b-2 border-blue-400'
|
|
||||||
: 'text-gray-400 hover:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MdPeople size={20} />
|
|
||||||
<span>Personas</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('chat')}
|
|
||||||
className={`flex-1 flex items-center justify-center space-x-2 py-3 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === 'chat'
|
|
||||||
? 'text-blue-400 border-b-2 border-blue-400'
|
|
||||||
: 'text-gray-400 hover:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MdChat size={20} />
|
|
||||||
<span>Chat</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('notes')}
|
|
||||||
className={`flex-1 flex items-center justify-center space-x-2 py-3 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === 'notes'
|
|
||||||
? 'text-blue-400 border-b-2 border-blue-400'
|
|
||||||
: 'text-gray-400 hover:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<MdNoteAdd size={20} />
|
|
||||||
<span>Notas</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 text-gray-400 hover:text-white transition-colors"
|
|
||||||
title="Cerrar panel"
|
|
||||||
>
|
|
||||||
<MdClose size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contenido */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
{activeTab === 'people' && <PeopleTab participants={participants} />}
|
|
||||||
{activeTab === 'chat' && <ChatTab />}
|
|
||||||
{activeTab === 'notes' && <NotesTab />}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const PeopleTab: React.FC<{ participants: any[] }> = ({ participants }) => {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-300">
|
|
||||||
{participants.length} en el studio
|
|
||||||
</h3>
|
|
||||||
<button className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors">
|
|
||||||
Invitar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{participants.map((participant) => (
|
|
||||||
<div
|
|
||||||
key={participant.sid}
|
|
||||||
className="flex items-center space-x-3 p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
|
|
||||||
<MdPeople size={20} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-white truncate">
|
|
||||||
{participant.identity}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
{participant.isLocal ? 'Tú (Presentador)' : 'Invitado'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button className="p-1 hover:bg-gray-500 rounded transition-colors">
|
|
||||||
<MdMoreVert size={20} className="text-gray-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChatTab: React.FC = () => {
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const [messages, setMessages] = useState<Array<{ user: string; text: string; time: string }>>([])
|
|
||||||
|
|
||||||
const sendMessage = () => {
|
|
||||||
if (message.trim()) {
|
|
||||||
setMessages([
|
|
||||||
...messages,
|
|
||||||
{
|
|
||||||
user: 'Tú',
|
|
||||||
text: message,
|
|
||||||
time: new Date().toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' }),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setMessage('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 space-y-3 mb-4">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="text-center text-gray-400 text-sm mt-8">
|
|
||||||
<MdChat size={48} className="mx-auto mb-2 opacity-50" />
|
|
||||||
<p>No hay mensajes aún</p>
|
|
||||||
<p className="text-xs mt-1">Inicia la conversación</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
messages.map((msg, idx) => (
|
|
||||||
<div key={idx} className="bg-gray-700 rounded-lg p-3">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-sm font-medium text-white">{msg.user}</span>
|
|
||||||
<span className="text-xs text-gray-400">{msg.time}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-300">{msg.text}</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
|
||||||
placeholder="Escribe un mensaje..."
|
|
||||||
className="flex-1 px-3 py-2 bg-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={sendMessage}
|
|
||||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Enviar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotesTab: React.FC = () => {
|
|
||||||
const [notes, setNotes] = useState('')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full">
|
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
|
||||||
placeholder="Escribe tus notas aquí..."
|
|
||||||
className="w-full h-full p-3 bg-gray-700 text-white rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StudioSidebar
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { DEMO_PARTICIPANTS } from '../config/demo'
|
|
||||||
import VideoConference from '../prefabs/VideoConference'
|
|
||||||
import AudioConference from '../prefabs/AudioConference'
|
|
||||||
|
|
||||||
interface StudioVideoAreaProps {
|
|
||||||
isDemoMode?: boolean
|
|
||||||
layout?: 'grid' | 'focus'
|
|
||||||
mode?: 'video' | 'audio'
|
|
||||||
}
|
|
||||||
|
|
||||||
const DemoModeView: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col bg-gray-950">
|
|
||||||
<div className="flex-1 p-4 grid grid-cols-2 gap-2">
|
|
||||||
{DEMO_PARTICIPANTS.map((p) => (
|
|
||||||
<div key={p.id} className="bg-gray-800 rounded-lg p-3 flex flex-col items-center justify-center">
|
|
||||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-pink-500 to-red-500 flex items-center justify-center text-white text-2xl font-bold mb-2">
|
|
||||||
{p.name.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="text-white text-sm font-medium">{p.name}</div>
|
|
||||||
<div className="text-gray-400 text-xs">{p.isMicrophoneEnabled ? 'Mic encendido' : 'Mic apagado'}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-900 border-t border-gray-800 p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h4 className="text-white text-sm font-medium">Participantes en Studio</h4>
|
|
||||||
<span className="text-gray-400 text-xs">({DEMO_PARTICIPANTS.length})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiveKitModeView: React.FC<{ layout: 'grid' | 'focus'; mode: 'video' | 'audio' }> = ({ layout, mode }) => {
|
|
||||||
if (mode === 'audio') return <AudioConference />
|
|
||||||
return <VideoConference layout={layout} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const StudioVideoArea: React.FC<StudioVideoAreaProps> = ({ isDemoMode = false, layout = 'grid', mode = 'video' }) => {
|
|
||||||
return isDemoMode ? <DemoModeView /> : <LiveKitModeView layout={layout} mode={mode} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StudioVideoArea
|
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { SceneProvider } from '../../context/SceneContext'
|
||||||
|
import StreamView from './StreamView'
|
||||||
|
import ControlPanel from './ControlPanel'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BroadcastStudio - Contenedor Principal de la UI del Estudio
|
||||||
|
*
|
||||||
|
* Estructura:
|
||||||
|
* - MainAppContainer: Contenedor centrado y alineado
|
||||||
|
* - StreamViewContainer: Área de visualización 16:9
|
||||||
|
* - ControlPanelWrapper: Barra de control (mismo ancho que StreamView)
|
||||||
|
*/
|
||||||
|
const BroadcastStudio: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<SceneProvider>
|
||||||
|
<div className="main-app-container flex flex-col items-center min-h-screen bg-gray-950 p-6">
|
||||||
|
{/* StreamView Container - Salida de video 16:9 */}
|
||||||
|
<div className="stream-view-container w-full max-w-6xl mb-4">
|
||||||
|
<StreamView />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control Panel - Mismo ancho que StreamView */}
|
||||||
|
<div className="control-panel-wrapper w-full max-w-6xl">
|
||||||
|
<ControlPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SceneProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BroadcastStudio
|
||||||
151
packages/studio-panel/src/components/broadcast/ControlPanel.tsx
Normal file
151
packages/studio-panel/src/components/broadcast/ControlPanel.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useLocalParticipant, useParticipants } from '@livekit/components-react'
|
||||||
|
import { useScene, PRESET_LAYOUTS } from '../../context/SceneContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalControls - Sección Izquierda del Panel de Control (estilo StreamYard)
|
||||||
|
* Vista previa local compacta + botón "Presentar o invitar"
|
||||||
|
*/
|
||||||
|
const LocalControls: React.FC = () => {
|
||||||
|
const { localParticipant } = useLocalParticipant()
|
||||||
|
const participants = useParticipants()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="local-controls flex-shrink-0 flex items-center gap-3">
|
||||||
|
{/* Preview local en esquina inferior izquierda (estilo StreamYard) */}
|
||||||
|
<div className="relative bg-gray-800 rounded-lg overflow-hidden w-32 h-24 border-2 border-gray-700">
|
||||||
|
{/* Avatar o video local */}
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white text-lg font-bold">
|
||||||
|
{localParticipant?.identity?.charAt(0).toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nombre en overlay */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-2 py-1">
|
||||||
|
<p className="text-white text-xs font-medium truncate">
|
||||||
|
{localParticipant?.identity || 'Tú'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Puntos de menú */}
|
||||||
|
<button className="absolute top-1 right-1 w-5 h-5 bg-black/40 hover:bg-black/60 rounded flex items-center justify-center transition-colors">
|
||||||
|
<span className="text-white text-xs">⋮</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón "Presentar o invitar" (estilo StreamYard) */}
|
||||||
|
<button className="bg-gray-800 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 border border-gray-700">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||||
|
</svg>
|
||||||
|
<span>Presentar o invitar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScrollableLayoutsContainer - Sección Central con Scroll Horizontal (estilo StreamYard)
|
||||||
|
* Botones de layout más compactos y agrupados
|
||||||
|
*/
|
||||||
|
const ScrollableLayoutsContainer: React.FC = () => {
|
||||||
|
const { sceneConfig, applyPreset } = useScene()
|
||||||
|
|
||||||
|
const layoutButtons = [
|
||||||
|
{ id: 'SINGLE_SPEAKER', label: '1 Persona', icon: '👤' },
|
||||||
|
{ id: 'SIDE_BY_SIDE', label: '2 Personas', icon: '👥' },
|
||||||
|
{ id: 'GRID_4', label: '2×2', icon: '⊞' },
|
||||||
|
{ id: 'GRID_6', label: '3×2', icon: '⊠' },
|
||||||
|
{ id: 'FOCUS_SIDE', label: 'Foco', icon: '◧' },
|
||||||
|
{ id: 'PRESENTATION', label: 'Pantalla', icon: '🖥️' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrollable-layouts-container flex-grow overflow-x-auto overflow-y-hidden">
|
||||||
|
<div className="layout-buttons-row flex flex-nowrap gap-2 h-full items-center">
|
||||||
|
{layoutButtons.map((btn) => {
|
||||||
|
const isActive = sceneConfig.participantLayout === PRESET_LAYOUTS[btn.id as keyof typeof PRESET_LAYOUTS]?.participantLayout
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={btn.id}
|
||||||
|
onClick={() => applyPreset(btn.id as keyof typeof PRESET_LAYOUTS)}
|
||||||
|
className={`
|
||||||
|
flex-shrink-0 h-16 w-16 rounded-lg border transition-all flex flex-col items-center justify-center gap-1
|
||||||
|
${isActive
|
||||||
|
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-500/50'
|
||||||
|
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600 hover:bg-gray-750'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={btn.label}
|
||||||
|
>
|
||||||
|
<div className="text-xl leading-none">{btn.icon}</div>
|
||||||
|
<div className="text-[10px] font-medium leading-none">{btn.label}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ActionControls - Sección Derecha del Panel de Control (estilo StreamYard)
|
||||||
|
* Botones de acción compactos
|
||||||
|
*/
|
||||||
|
const ActionControls: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="action-controls flex-shrink-0 flex gap-2 items-center">
|
||||||
|
<button
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white h-16 px-4 rounded-lg transition-colors flex items-center gap-2 font-medium shadow-lg"
|
||||||
|
title="Editor de escenas"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">Editar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 text-white h-16 w-16 rounded-lg transition-colors flex items-center justify-center"
|
||||||
|
title="Añadir recursos"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 text-white h-16 w-16 rounded-lg transition-colors flex items-center justify-center"
|
||||||
|
title="Configuración"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ControlPanel - Panel Interactivo Adaptable (3 secciones) - Estilo StreamYard
|
||||||
|
*
|
||||||
|
* Estructura horizontal compacta: LocalControls | ScrollableLayouts | ActionControls
|
||||||
|
*/
|
||||||
|
const ControlPanel: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="control-panel-wrapper w-full max-w-6xl mx-auto">
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl p-3 shadow-2xl">
|
||||||
|
<div className="flex items-center gap-3 h-20">
|
||||||
|
<LocalControls />
|
||||||
|
<ScrollableLayoutsContainer />
|
||||||
|
<ActionControls />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ControlPanel
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react'
|
||||||
|
import '@livekit/components-styles'
|
||||||
|
import BroadcastStudio from './BroadcastStudio'
|
||||||
|
|
||||||
|
interface LiveKitBroadcastWrapperProps {
|
||||||
|
token: string
|
||||||
|
serverUrl: string
|
||||||
|
userName: string
|
||||||
|
roomName: string
|
||||||
|
onDisconnect?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LiveKitBroadcastWrapper - Envuelve el BroadcastStudio con LiveKitRoom
|
||||||
|
*
|
||||||
|
* Proporciona el contexto de LiveKit (participantes, tracks, etc.)
|
||||||
|
* al BroadcastStudio y sus componentes hijos.
|
||||||
|
*/
|
||||||
|
const LiveKitBroadcastWrapper: React.FC<LiveKitBroadcastWrapperProps> = ({
|
||||||
|
token,
|
||||||
|
serverUrl,
|
||||||
|
userName,
|
||||||
|
roomName,
|
||||||
|
onDisconnect,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<LiveKitRoom
|
||||||
|
token={token}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
connect={true}
|
||||||
|
audio={true}
|
||||||
|
video={true}
|
||||||
|
onDisconnected={() => {
|
||||||
|
console.log('[LiveKitBroadcast] Desconectado de la sala')
|
||||||
|
onDisconnect?.()
|
||||||
|
}}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('[LiveKitBroadcast] Error en LiveKit:', error)
|
||||||
|
}}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
{/* Audio renderer para escuchar a los participantes */}
|
||||||
|
<RoomAudioRenderer />
|
||||||
|
|
||||||
|
{/* Interfaz del estudio */}
|
||||||
|
<BroadcastStudio />
|
||||||
|
</LiveKitRoom>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LiveKitBroadcastWrapper
|
||||||
178
packages/studio-panel/src/components/broadcast/StreamView.tsx
Normal file
178
packages/studio-panel/src/components/broadcast/StreamView.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useParticipants, useTracks, VideoTrack } from '@livekit/components-react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { useScene } from '../../context/SceneContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StreamView - La Plantilla de Visualización (Salida de Video 16:9) - Estilo StreamYard
|
||||||
|
*
|
||||||
|
* Renderiza el resultado final con overlays y branding
|
||||||
|
*/
|
||||||
|
const StreamView: React.FC = () => {
|
||||||
|
const { sceneConfig } = useScene()
|
||||||
|
const participants = useParticipants()
|
||||||
|
const videoTracks = useTracks([Track.Source.Camera], { onlySubscribed: false })
|
||||||
|
|
||||||
|
// Renderizar overlays (logos, lower thirds) según configuración
|
||||||
|
const renderOverlays = () => {
|
||||||
|
const { overlays } = sceneConfig
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Logo superior izquierdo */}
|
||||||
|
{overlays.logo && (
|
||||||
|
<div className="absolute top-4 left-4 z-10">
|
||||||
|
<img src={overlays.logo.url} alt="Logo" className="h-12 drop-shadow-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lower third - Nombre del presentador (estilo StreamYard) */}
|
||||||
|
{overlays.lowerThird && (
|
||||||
|
<div className="absolute bottom-8 left-6 z-10 bg-gradient-to-r from-purple-600 to-pink-600 px-4 py-2 rounded-lg shadow-xl">
|
||||||
|
<p className="text-white font-bold text-lg">{overlays.lowerThird.name}</p>
|
||||||
|
{overlays.lowerThird.title && (
|
||||||
|
<p className="text-white/90 text-sm">{overlays.lowerThird.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlays de engagement (LIKE, SUBSCRIBE) - estilo StreamYard */}
|
||||||
|
<div className="absolute bottom-32 left-6 flex flex-col gap-2 z-10">
|
||||||
|
<div className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-full flex items-center gap-2 shadow-lg cursor-pointer transform hover:scale-105 transition-transform">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-white font-bold text-sm">LIKE</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-red-600 hover:bg-red-700 px-4 py-2 rounded-full flex items-center gap-2 shadow-lg cursor-pointer transform hover:scale-105 transition-transform">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-white font-bold text-sm">SUBSCRIBE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicador de calidad 720p (superior izquierdo) */}
|
||||||
|
<div className="absolute top-4 left-4 z-10 bg-black/60 backdrop-blur-sm px-3 py-1 rounded-md">
|
||||||
|
<span className="text-white text-xs font-bold">720p</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderizar layout según configuración
|
||||||
|
const renderLayout = () => {
|
||||||
|
const { participantLayout, mediaSource } = sceneConfig
|
||||||
|
|
||||||
|
switch (participantLayout) {
|
||||||
|
case 'grid_4':
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 grid-rows-2 gap-2 w-full h-full p-4">
|
||||||
|
{videoTracks.slice(0, 4).map((track, idx) => (
|
||||||
|
<div key={idx} className="bg-gray-900 rounded-lg overflow-hidden">
|
||||||
|
<VideoTrack trackRef={track} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'grid_6':
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 grid-rows-2 gap-2 w-full h-full p-4">
|
||||||
|
{videoTracks.slice(0, 6).map((track, idx) => (
|
||||||
|
<div key={idx} className="bg-gray-900 rounded-lg overflow-hidden">
|
||||||
|
<VideoTrack trackRef={track} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'focus_side':
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 w-full h-full p-4">
|
||||||
|
{/* Main speaker */}
|
||||||
|
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden">
|
||||||
|
{videoTracks[0] && <VideoTrack trackRef={videoTracks[0]} />}
|
||||||
|
</div>
|
||||||
|
{/* Sidebar con otros participantes */}
|
||||||
|
<div className="w-48 flex flex-col gap-2">
|
||||||
|
{videoTracks.slice(1, 4).map((track, idx) => (
|
||||||
|
<div key={idx} className="flex-1 bg-gray-900 rounded-lg overflow-hidden">
|
||||||
|
<VideoTrack trackRef={track} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'side_by_side':
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 w-full h-full p-4">
|
||||||
|
{videoTracks.slice(0, 2).map((track, idx) => (
|
||||||
|
<div key={idx} className="flex-1 bg-gray-900 rounded-lg overflow-hidden">
|
||||||
|
<VideoTrack trackRef={track} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'presentation':
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full p-4">
|
||||||
|
{/* Área de presentación */}
|
||||||
|
<div className="w-full h-full bg-gray-900 rounded-lg overflow-hidden flex items-center justify-center">
|
||||||
|
{mediaSource?.type === 'screen' && mediaSource.stream && (
|
||||||
|
<video
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && mediaSource.stream) {
|
||||||
|
el.srcObject = mediaSource.stream
|
||||||
|
el.play()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{mediaSource?.type === 'file' && mediaSource.url && (
|
||||||
|
<img src={mediaSource.url} alt="Presentation" className="w-full h-full object-contain" />
|
||||||
|
)}
|
||||||
|
{!mediaSource && (
|
||||||
|
<div className="text-gray-500 text-xl">Esperando contenido...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Speaker pequeño en esquina */}
|
||||||
|
<div className="absolute bottom-8 right-8 w-48 h-32 bg-gray-900 rounded-lg overflow-hidden shadow-2xl">
|
||||||
|
{videoTracks[0] && <VideoTrack trackRef={videoTracks[0]} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'single_speaker':
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full p-4">
|
||||||
|
<div className="w-full h-full bg-gray-900 rounded-lg overflow-hidden">
|
||||||
|
{videoTracks[0] && <VideoTrack trackRef={videoTracks[0]} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-full h-full text-gray-500">
|
||||||
|
Layout no configurado
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stream-view-container relative w-full bg-black rounded-lg overflow-hidden shadow-2xl" style={{ aspectRatio: '16 / 9' }}>
|
||||||
|
{renderLayout()}
|
||||||
|
{renderOverlays()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StreamView
|
||||||
5
packages/studio-panel/src/components/broadcast/index.ts
Normal file
5
packages/studio-panel/src/components/broadcast/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { default as BroadcastStudio } from './BroadcastStudio'
|
||||||
|
export { default as StreamView } from './StreamView'
|
||||||
|
export { default as ControlPanel } from './ControlPanel'
|
||||||
|
export { default as LiveKitBroadcastWrapper } from './LiveKitBroadcastWrapper'
|
||||||
|
export * from '../../context/SceneContext'
|
||||||
114
packages/studio-panel/src/context/SceneContext.tsx
Normal file
114
packages/studio-panel/src/context/SceneContext.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import React, { createContext, useContext, useState, ReactNode } from 'react'
|
||||||
|
|
||||||
|
// Tipos de layouts disponibles
|
||||||
|
export type ParticipantLayoutType =
|
||||||
|
| 'grid_4' // Grid 2x2
|
||||||
|
| 'grid_6' // Grid 3x2
|
||||||
|
| 'focus_side' // Foco principal + sidebar
|
||||||
|
| 'side_by_side' // Lado a lado
|
||||||
|
| 'presentation' // Presentación con speaker pequeño
|
||||||
|
| 'single_speaker' // Un solo participante
|
||||||
|
|
||||||
|
export type MediaSourceType = {
|
||||||
|
type: 'screen' | 'file' | 'image' | 'pdf' | 'video' | null
|
||||||
|
url?: string
|
||||||
|
stream?: MediaStream
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverlayConfig = {
|
||||||
|
showLowerThird?: boolean
|
||||||
|
lowerThirdText?: string
|
||||||
|
showLogo?: boolean
|
||||||
|
logoPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneConfig {
|
||||||
|
participantLayout: ParticipantLayoutType
|
||||||
|
mediaSource: MediaSourceType | null
|
||||||
|
overlays: OverlayConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets de layouts predefinidos
|
||||||
|
export const PRESET_LAYOUTS: Record<string, SceneConfig> = {
|
||||||
|
GRID_4: {
|
||||||
|
participantLayout: 'grid_4',
|
||||||
|
mediaSource: null,
|
||||||
|
overlays: { showLowerThird: false, showLogo: true, logoPosition: 'top-right' },
|
||||||
|
},
|
||||||
|
GRID_6: {
|
||||||
|
participantLayout: 'grid_6',
|
||||||
|
mediaSource: null,
|
||||||
|
overlays: { showLowerThird: false, showLogo: true, logoPosition: 'top-right' },
|
||||||
|
},
|
||||||
|
FOCUS_SIDE: {
|
||||||
|
participantLayout: 'focus_side',
|
||||||
|
mediaSource: null,
|
||||||
|
overlays: { showLowerThird: true, showLogo: true, logoPosition: 'top-right' },
|
||||||
|
},
|
||||||
|
SIDE_BY_SIDE: {
|
||||||
|
participantLayout: 'side_by_side',
|
||||||
|
mediaSource: null,
|
||||||
|
overlays: { showLowerThird: false, showLogo: true, logoPosition: 'bottom-right' },
|
||||||
|
},
|
||||||
|
PRESENTATION: {
|
||||||
|
participantLayout: 'presentation',
|
||||||
|
mediaSource: null,
|
||||||
|
overlays: { showLowerThird: false, showLogo: false },
|
||||||
|
},
|
||||||
|
SINGLE_SPEAKER: {
|
||||||
|
participantLayout: 'single_speaker',
|
||||||
|
mediaSource: null,
|
||||||
|
overlays: { showLowerThird: true, showLogo: true, logoPosition: 'top-left' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SceneContextType {
|
||||||
|
sceneConfig: SceneConfig
|
||||||
|
setSceneConfig: (config: SceneConfig) => void
|
||||||
|
applyPreset: (presetKey: keyof typeof PRESET_LAYOUTS) => void
|
||||||
|
updateMediaSource: (source: MediaSourceType | null) => void
|
||||||
|
updateOverlays: (overlays: Partial<OverlayConfig>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SceneContext = createContext<SceneContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export const SceneProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [sceneConfig, setSceneConfig] = useState<SceneConfig>(PRESET_LAYOUTS.GRID_4)
|
||||||
|
|
||||||
|
const applyPreset = (presetKey: keyof typeof PRESET_LAYOUTS) => {
|
||||||
|
setSceneConfig(PRESET_LAYOUTS[presetKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMediaSource = (source: MediaSourceType | null) => {
|
||||||
|
setSceneConfig((prev) => ({ ...prev, mediaSource: source }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOverlays = (overlays: Partial<OverlayConfig>) => {
|
||||||
|
setSceneConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
overlays: { ...prev.overlays, ...overlays },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SceneContext.Provider
|
||||||
|
value={{
|
||||||
|
sceneConfig,
|
||||||
|
setSceneConfig,
|
||||||
|
applyPreset,
|
||||||
|
updateMediaSource,
|
||||||
|
updateOverlays,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SceneContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useScene = () => {
|
||||||
|
const context = useContext(SceneContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useScene must be used within SceneProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user