refactor: remove unused components and demo configurations
- Deleted Broadcast, AudioConference, Chat, ControlBar, PreJoin, VideoConference components. - Removed demo configuration file and related context. - Cleaned up index files and hooks related to destinations and scenes. - Introduced new Header and DestinationModal components for better UI management. - Updated global styles and layout structure for improved responsiveness.
This commit is contained in:
parent
3985ee60ca
commit
198fbe8ef6
@ -1,42 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Studio from './components/Studio'
|
||||
import React from 'react'
|
||||
import StudioLayout from './layouts/StudioLayout'
|
||||
|
||||
function App() {
|
||||
const [userName, setUserName] = useState<string>('')
|
||||
const [roomName, setRoomName] = useState<string>('avanzacast-studio')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Obtener información del usuario desde localStorage o URL params
|
||||
// Esta información será establecida desde broadcast-panel
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const userFromParams = params.get('user')
|
||||
const roomFromParams = params.get('room')
|
||||
|
||||
const userFromStorage = localStorage.getItem('avanzacast_user')
|
||||
const roomFromStorage = localStorage.getItem('avanzacast_room')
|
||||
|
||||
setUserName(userFromParams || userFromStorage || 'Demo User')
|
||||
setRoomName(roomFromParams || roomFromStorage || 'avanzacast-studio')
|
||||
|
||||
// Dar un pequeño delay para mostrar el loading
|
||||
setTimeout(() => setLoading(false), 500)
|
||||
}, [])
|
||||
|
||||
// Mostrar pantalla de carga mientras se obtiene la información
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-pink-500 border-r-transparent mb-4"></div>
|
||||
<h2 className="text-xl font-semibold text-white">Cargando Studio...</h2>
|
||||
<p className="text-gray-400 mt-2">Conectando con AvanzaCast</p>
|
||||
</div>
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<StudioLayout>
|
||||
<div className="p-4 text-white">
|
||||
<h1 className="text-2xl font-semibold">AvanzaCast - Studio</h1>
|
||||
<p className="text-sm text-gray-300 mt-2">Panel de pruebas del estudio.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Studio userName={userName} roomName={roomName} />
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@ -1,138 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import apiClient from '@avanzacast/shared-utils/api'
|
||||
|
||||
interface InvitedParticipant {
|
||||
identity: string
|
||||
id?: string
|
||||
metadata?: any
|
||||
}
|
||||
|
||||
interface ParticipantsPanelProps {
|
||||
roomName?: string
|
||||
}
|
||||
|
||||
const ParticipantsPanel: React.FC<ParticipantsPanelProps> = ({ roomName }) => {
|
||||
const liveParticipants = useParticipants()
|
||||
const [invited, setInvited] = useState<InvitedParticipant[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomName) return
|
||||
let mounted = true
|
||||
const fetchInvited = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await apiClient.get(`/livekit/rooms?roomName=${encodeURIComponent(roomName as string)}`)
|
||||
if (result.success) {
|
||||
const json: any = result.data || {}
|
||||
const list = (json?.room?.participants) || []
|
||||
if (mounted) setInvited(list)
|
||||
} else {
|
||||
// Fallback: try Next.js app running on localhost:3000 (some environments expose the route there)
|
||||
try {
|
||||
const nextUrl = `http://localhost:3000/api/livekit/rooms?roomName=${encodeURIComponent(roomName as string)}`
|
||||
const resp = await fetch(nextUrl, { headers: { 'Content-Type': 'application/json' } })
|
||||
const data = await resp.json()
|
||||
const list = (data?.room?.participants) || []
|
||||
if (mounted) setInvited(list)
|
||||
} catch (nfErr) {
|
||||
throw new Error(result.error?.message || 'API error')
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[ParticipantsPanel] error fetching invited', e)
|
||||
if (mounted) setError(String(e?.message || e))
|
||||
} finally {
|
||||
if (mounted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchInvited()
|
||||
return () => { mounted = false }
|
||||
}, [roomName])
|
||||
|
||||
const isConnected = (identity: string) => liveParticipants.some((p: any) => p.identity === identity)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h3 className="text-white font-semibold text-sm">Participantes</h3>
|
||||
<p className="text-gray-400 text-xs mt-1">
|
||||
Gestiona quién está en el stream
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Conectados</h4>
|
||||
{liveParticipants.length === 0 ? (
|
||||
<p className="text-gray-400 text-xs">No hay participantes conectados.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{liveParticipants.map((p: any) => (
|
||||
<div key={p.sid} className="flex items-center justify-between bg-gray-700 p-3 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-pink-500 to-red-500 flex items-center justify-center text-white font-medium text-sm">
|
||||
{p.identity?.charAt(0)?.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium truncate">{p.identity}</p>
|
||||
<p className="text-xs text-gray-400">{p.isLocal ? 'Tú (presentador)' : 'Invitado'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="text-xs px-2 py-1 bg-gray-600 hover:bg-gray-500 rounded text-white transition-colors">
|
||||
Silenciar
|
||||
</button>
|
||||
<button className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-white transition-colors">
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white text-sm font-semibold mb-3">Invitados</h4>
|
||||
{loading ? (
|
||||
<p className="text-gray-400 text-xs">Cargando...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-400 text-xs">Error: {error}</p>
|
||||
) : invited.length === 0 ? (
|
||||
<p className="text-gray-400 text-xs">No hay invitados registrados.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{invited.map((inv) => (
|
||||
<div key={inv.identity} className={`flex items-center justify-between bg-gray-700 p-3 rounded-lg ${isConnected(inv.identity) ? 'ring-2 ring-green-500' : ''}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-gray-600 flex items-center justify-center text-white font-medium text-sm">
|
||||
{inv.identity?.charAt(0)?.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-white font-medium truncate">{inv.identity}</p>
|
||||
<p className="text-xs text-gray-400">{isConnected(inv.identity) ? 'Conectado' : 'Esperando'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 rounded text-white transition-colors">
|
||||
Invitar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ParticipantsPanel
|
||||
@ -1,57 +0,0 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
|
||||
interface PresentationPanelProps {
|
||||
onClose: () => void
|
||||
onShareScreen: () => void
|
||||
onShareFile: (file: File) => void
|
||||
}
|
||||
|
||||
const PresentationPanel: React.FC<PresentationPanelProps> = ({ onClose, onShareScreen, onShareFile }) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
setUploading(true)
|
||||
try {
|
||||
onShareFile(f)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 p-4 rounded shadow-md">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-white text-sm font-semibold">Compartir presentación</h3>
|
||||
<button onClick={onClose} className="text-gray-300 text-sm">Cerrar</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={onShareScreen}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded"
|
||||
>
|
||||
Compartir pantalla
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,application/pdf"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="presentation-file"
|
||||
/>
|
||||
<label htmlFor="presentation-file" className="w-full block text-center bg-green-600 hover:bg-green-700 text-white py-2 rounded cursor-pointer">
|
||||
{uploading ? 'Subiendo...' : 'Subir archivo (imagen, video, pdf)'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PresentationPanel
|
||||
@ -1,310 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
LiveKitRoom,
|
||||
ControlBar,
|
||||
useTracks,
|
||||
useLocalParticipant
|
||||
} 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>("comments");
|
||||
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">
|
||||
{/* Header */}
|
||||
<div className="flex-none z-40">
|
||||
<StudioHeader roomName={roomName} userName={userName} />
|
||||
</div>
|
||||
|
||||
{/* Contenedor principal entre header y footer */}
|
||||
<div className="flex-1 overflow-hidden relative min-h-0">
|
||||
{/* Panel izquierdo */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 z-20 transition-transform duration-300 ${showLeftPanel ? "translate-x-0" : "-translate-x-full"}`}
|
||||
>
|
||||
<StudioLeftSidebar />
|
||||
</div>
|
||||
|
||||
{/* Botón toggle panel izquierdo */}
|
||||
<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>
|
||||
{/* Contenedor del estudio (StreamView + ControlPanel) */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 transition-all duration-300 flex flex-col overflow-hidden"
|
||||
style={{
|
||||
left: showLeftPanel ? "256px" : "0px",
|
||||
right: showRightPanel ? "480px" : "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>
|
||||
{/* Panel derecho: tabs siempre visibles en el extremo derecho, contenido se oculta */}
|
||||
{/* Panel de contenido con animación de slide */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 z-20 transition-transform duration-300 ${showRightPanel ? "translate-x-0" : "translate-x-full"}`}
|
||||
style={{ right: "80px" }}
|
||||
>
|
||||
<StudioRightPanel
|
||||
activeTab={activeRightTab}
|
||||
onChangeTab={(t: TabType) => setActiveRightTab(t)}
|
||||
/>
|
||||
</div>
|
||||
{/* TabsColumn siempre visible en el extremo derecho */}
|
||||
<div className="absolute right-0 top-0 bottom-0 z-30">
|
||||
<TabsColumn
|
||||
activeTab={activeRightTab}
|
||||
onChangeTab={(t: TabType) => {
|
||||
setActiveRightTab(t);
|
||||
if (!showRightPanel) {
|
||||
setShowRightPanel(true);
|
||||
}
|
||||
}}
|
||||
onTogglePanel={() => setShowRightPanel(!showRightPanel)}
|
||||
isCollapsed={!showRightPanel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ControlBar como footer fijo */}
|
||||
<div
|
||||
className="flex-none h-20 z-40 transition-all duration-300 bg-gray-900/95 backdrop-blur-sm border-t border-gray-800"
|
||||
style={{
|
||||
marginLeft: showLeftPanel ? "256px" : "0px",
|
||||
marginRight: showRightPanel ? "480px" : "80px",
|
||||
}}
|
||||
>
|
||||
<ControlBar
|
||||
variation="verbose"
|
||||
controls={{
|
||||
microphone: true,
|
||||
camera: true,
|
||||
screenShare: true,
|
||||
leave: true,
|
||||
}}
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 1rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SceneProvider>
|
||||
</LiveKitRoom>
|
||||
);
|
||||
}
|
||||
|
||||
export default Studio;
|
||||
@ -1,789 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react'
|
||||
import '@livekit/components-styles'
|
||||
import StudioHeader from './StudioHeader'
|
||||
import StudioLeftSidebar from './StudioLeftSidebar'
|
||||
import StudioRightPanel, { TabsColumn, TabType } from './StudioRightPanel'
|
||||
import { DEMO_TOKEN } from '../config/demo'
|
||||
import StreamView from './broadcast/StreamView'
|
||||
import ControlPanel from './broadcast/ControlPanel'
|
||||
import { SceneProvider } from '../context/SceneContext'
|
||||
|
||||
interface StudioProps {
|
||||
userName: string
|
||||
roomName: string
|
||||
}
|
||||
|
||||
const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
|
||||
const [token, setToken] = useState<string>(DEMO_TOKEN)
|
||||
const [serverUrl, setServerUrl] = useState<string>('')
|
||||
const [isConnecting, setIsConnecting] = useState(true)
|
||||
const [isDemoMode, setIsDemoMode] = useState(true) // Iniciar en modo demo por defecto
|
||||
const [showLeftPanel, setShowLeftPanel] = useState(true)
|
||||
const [showRightPanel, setShowRightPanel] = useState(true)
|
||||
const [activeRightTab, setActiveRightTab] = useState<TabType>('brand')
|
||||
|
||||
// Utility: heurística ligera para validar token en cliente
|
||||
const isTokenLikelyValid = (t?: string) => {
|
||||
if (!t) return false
|
||||
// los tokens de LiveKit suelen ser JWT (contienen '.') o bastante largos
|
||||
if (t.includes('.')) return true
|
||||
if (t.length > 80) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const decodeJwt = (t: string) => {
|
||||
try {
|
||||
const parts = t.split('.')
|
||||
if (parts.length < 2) return null
|
||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=')
|
||||
const json = JSON.parse(atob(padded))
|
||||
return json
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Log decoded token claims for diagnostic when token changes
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
const claims = decodeJwt(token)
|
||||
if (claims) {
|
||||
console.log('[Studio] token claims:', claims)
|
||||
} else {
|
||||
console.log('[Studio] token does not look like JWT or could not be decoded')
|
||||
}
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
// Leer token, room y user desde URL query params (pasados por broadcast-panel)
|
||||
// El serverUrl se lee del .env
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const storedToken = urlParams.get('token')
|
||||
const storedRoom = urlParams.get('room')
|
||||
const storedUser = urlParams.get('user')
|
||||
|
||||
// ServerUrl siempre viene del .env
|
||||
const envServerUrl = import.meta.env.VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
|
||||
|
||||
// Log detallado para depuración
|
||||
console.log('[Studio] Leyendo datos de URL params:', {
|
||||
token: storedToken ? `recibido (${storedToken.length} caracteres)` : 'no encontrado',
|
||||
room: storedRoom || 'no encontrado',
|
||||
user: storedUser || 'no encontrado',
|
||||
})
|
||||
console.log('[Studio] ServerUrl desde .env:', envServerUrl)
|
||||
|
||||
if (storedToken) {
|
||||
console.log('[Studio] Token encontrado en URL. Configurando para conexión real.')
|
||||
setToken(storedToken)
|
||||
setServerUrl(envServerUrl)
|
||||
setIsDemoMode(false)
|
||||
|
||||
// Guardar en localStorage para que persista en recargas
|
||||
localStorage.setItem('avanzacast_studio_token', storedToken)
|
||||
localStorage.setItem('avanzacast_studio_serverUrl', envServerUrl)
|
||||
if (storedRoom) localStorage.setItem('avanzacast_studio_room', storedRoom)
|
||||
if (storedUser) localStorage.setItem('avanzacast_studio_user', storedUser)
|
||||
|
||||
// Limpiar URL para que no se vea el token
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
} else {
|
||||
// Intentar leer de localStorage (si ya se guardó antes)
|
||||
const cachedToken = localStorage.getItem('avanzacast_studio_token')
|
||||
const cachedServerUrl = localStorage.getItem('avanzacast_studio_serverUrl')
|
||||
|
||||
if (cachedToken && cachedServerUrl) {
|
||||
console.log('[Studio] Token y Server URL encontrados en localStorage. Configurando para conexión real.')
|
||||
setToken(cachedToken)
|
||||
setServerUrl(cachedServerUrl)
|
||||
setIsDemoMode(false)
|
||||
} else {
|
||||
console.warn('⚠️ No se encontró token en URL ni localStorage. Usando modo DEMO...')
|
||||
setToken(DEMO_TOKEN)
|
||||
setServerUrl(envServerUrl)
|
||||
setIsDemoMode(true)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error crítico leyendo datos:', err)
|
||||
setToken(DEMO_TOKEN)
|
||||
setIsDemoMode(true)
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}, [roomName, userName])
|
||||
|
||||
// Dev-only: wrap window.WebSocket once to capture close codes/reasons/messages
|
||||
useEffect(() => {
|
||||
try {
|
||||
const w = window as any
|
||||
if (w && !w.__AVZ_WS_WRAPPED) {
|
||||
const OriginalWS = w.WebSocket
|
||||
function WrappedWebSocket(url: string, protocols?: string | string[]) {
|
||||
console.log(`[WS Monitor] new WebSocket(${url})`)
|
||||
// @ts-ignore
|
||||
const sock = protocols ? new OriginalWS(url, protocols) : new OriginalWS(url)
|
||||
try {
|
||||
sock.addEventListener('open', (ev: Event) => console.log('[WS Monitor] Evento: open', { url, type: (ev && (ev as any).type) || 'open' }))
|
||||
sock.addEventListener('close', (ev: any) => console.log('[WS Monitor] Evento: close', { url, code: ev.code, reason: ev.reason, wasClean: ev.wasClean }))
|
||||
sock.addEventListener('error', (ev: any) => console.error('[WS Monitor] Evento: error', { url, ev }))
|
||||
sock.addEventListener('message', (ev: any) => {
|
||||
// don't log binary payloads
|
||||
try {
|
||||
const data = typeof ev.data === 'string' ? ev.data : '[payload binario]'
|
||||
// limit length
|
||||
console.log('[WS Monitor] Evento: message', { url, data: data && data.slice ? data.slice(0, 250) : data })
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return sock
|
||||
}
|
||||
WrappedWebSocket.prototype = OriginalWS.prototype
|
||||
w.WebSocket = WrappedWebSocket
|
||||
w.__AVZ_WS_WRAPPED = true
|
||||
console.log('[Studio] WebSocket envuelto para depuración: se registrarán todos los eventos de WS.')
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore failures in exotic environments
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Global error capture to help debugging connection failures
|
||||
useEffect(() => {
|
||||
const onErr = (ev: ErrorEvent) => console.error('[Studio][Error Global]', { message: ev.message, error: ev.error, filename: ev.filename, lineno: ev.lineno })
|
||||
const onReject = (ev: PromiseRejectionEvent) => console.error('[Studio][Promesa Rechazada]', ev.reason)
|
||||
window.addEventListener('error', onErr)
|
||||
window.addEventListener('unhandledrejection', onReject)
|
||||
return () => {
|
||||
window.removeEventListener('error', onErr)
|
||||
window.removeEventListener('unhandledrejection', onReject)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Development-only WebSocket monitor: wrap window.WebSocket to log events for debugging
|
||||
// NOTE: development WebSocket monitor removed to keep hooks stable during HMR
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`[Studio] Renderizando LiveKitRoom. Longitud del token: ${token ? token.length : 0}. ¿Es válido?: ${isTokenLikelyValid(token)}`)
|
||||
return () => console.log('[Studio] Desmontando LiveKitRoom')
|
||||
}, [token])
|
||||
|
||||
if (isConnecting) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-pink-500 mx-auto mb-4"></div>
|
||||
<p className="text-white text-lg">Conectando al estudio...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Heurística para no montar LiveKitRoom con tokens de demo o cortos
|
||||
const hasValidToken = isTokenLikelyValid(token)
|
||||
const hasValidServerUrl = serverUrl && serverUrl.startsWith('wss://')
|
||||
|
||||
console.log('[Studio] Validación antes de renderizar:', { hasValidToken, hasValidServerUrl, serverUrl })
|
||||
|
||||
if (!hasValidToken || !hasValidServerUrl) {
|
||||
// Mostrar diagnóstico más útil cuando el token es claramente inválido/corto
|
||||
const preview = token ? `${token.slice(0, 6)}...${token.slice(-6)}` : 'ninguno'
|
||||
console.error(`[Studio] Token o ServerUrl inválidos, no se montará LiveKitRoom.`)
|
||||
console.error(`[Studio] Token preview: ${preview}, Longitud: ${token ? token.length : 0}`)
|
||||
console.error(`[Studio] ServerUrl: ${serverUrl || 'ninguno'}`)
|
||||
|
||||
const broadcastUrl = import.meta.env.VITE_BROADCAST_URL || 'http://localhost:5175'
|
||||
const goBackToBroadcast = () => {
|
||||
// Limpiar localStorage (por si acaso quedaron datos)
|
||||
try {
|
||||
console.log('[Studio] Limpiando localStorage antes de volver.')
|
||||
localStorage.removeItem('avanzacast_studio_token')
|
||||
localStorage.removeItem('avanzacast_studio_serverUrl')
|
||||
localStorage.removeItem('avanzacast_studio_room')
|
||||
localStorage.removeItem('avanzacast_studio_user')
|
||||
} catch (e) {}
|
||||
|
||||
// Intentar volver atrás en el historial si hay entrada previa
|
||||
if (window.history.length > 1) {
|
||||
console.log('[Studio] Volviendo atrás en el historial.')
|
||||
window.history.back()
|
||||
} else {
|
||||
// Redirigir al broadcast
|
||||
console.log(`[Studio] No hay historial, redirigiendo a ${broadcastUrl}`)
|
||||
window.location.href = broadcastUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Modal bloqueante: overlay completo que no permite interacción con UI
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] bg-gray-900 bg-opacity-90 flex items-center justify-center">
|
||||
{/* Modal */}
|
||||
<div role="dialog" aria-modal="true" className="bg-gray-800 p-8 rounded-lg max-w-md w-full mx-4 shadow-2xl border border-red-500/50">
|
||||
<div className="flex items-center mb-4">
|
||||
<svg className="w-8 h-8 text-red-500 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-xl text-white font-semibold">Error de Conexión</h3>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-3">No se pudo conectar al estudio porque no se obtuvo un token de autenticación válido.</p>
|
||||
<p className="text-gray-400 text-sm mb-6">Esto puede ocurrir si el servidor de tokens no está disponible o si el token ha expirado.</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="py-2 px-4 bg-slate-600 hover:bg-slate-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
<button
|
||||
onClick={goBackToBroadcast}
|
||||
className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Volver al Panel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Renderizar interfaz en modo demo (sin LiveKit)
|
||||
if (isDemoMode) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-900">
|
||||
{/* Banner de modo demo */}
|
||||
<div className="bg-yellow-600 text-black px-4 py-2 text-center text-sm font-semibold">
|
||||
⚠️ MODO DEMO - No se encontró un token en sessionStorage. Funcionalidad de streaming deshabilitada.
|
||||
</div>
|
||||
|
||||
{/* Header superior */}
|
||||
<StudioHeader roomName={roomName} userName={userName} />
|
||||
|
||||
{/* Contenido principal */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{/* Sidebar izquierdo - Escenas (posición absoluta izquierda) */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
|
||||
showLeftPanel ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<StudioLeftSidebar />
|
||||
</div>
|
||||
|
||||
{/* Botón toggle izquierdo (en el borde derecho del panel izquierdo) */}
|
||||
<button
|
||||
onClick={() => setShowLeftPanel(!showLeftPanel)}
|
||||
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
|
||||
style={{ left: showLeftPanel ? '256px' : '0px' }}
|
||||
title={showLeftPanel ? 'Ocultar panel izquierdo' : 'Mostrar panel izquierdo'}
|
||||
>
|
||||
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity">
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
|
||||
<path d="M0.5 12C0.5 5.64873 5.64873 0.5 12 0.5H15.5V100.5H12C5.64873 100.5 0.5 95.3513 0.5 89V12Z" stroke="#1B1F291A"/>
|
||||
<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="#1B1F29"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Contenedor central - Área entre paneles laterales */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
left: showLeftPanel ? '256px' : '0px',
|
||||
right: showRightPanel ? '400px' : '80px'
|
||||
}}
|
||||
>
|
||||
{/* Wrapper interno con padding y espacio para UI adicional */}
|
||||
<div className="w-full h-full flex flex-col p-4 gap-3">
|
||||
{/* Área superior - Controles de layout y calidad */}
|
||||
<div className="flex-none flex items-center justify-between gap-4">
|
||||
{/* Indicador de calidad */}
|
||||
<div className="flex items-center gap-2 bg-gray-800 px-3 py-1.5 rounded-lg">
|
||||
<span className="text-white text-sm font-semibold">720p</span>
|
||||
</div>
|
||||
|
||||
{/* Controles de layout (Grid/Focus) */}
|
||||
<div className="flex items-center gap-2 bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setLayout('grid')}
|
||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
layout === 'grid'
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Vista de cuadrícula"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="7" height="7" strokeWidth="2" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" strokeWidth="2" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" strokeWidth="2" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" strokeWidth="2" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLayout('focus')}
|
||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
layout === 'focus'
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Vista enfocada"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="12" strokeWidth="2" rx="1"/>
|
||||
<rect x="4" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
|
||||
<rect x="10" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
|
||||
<rect x="16" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Branding - Logo StreamYard style */}
|
||||
<div className="flex items-center gap-2 text-gray-400 text-xs ml-auto">
|
||||
<span>Producido con</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-6 h-6 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="text-white font-semibold">AvanzaCast</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* StudioVideoArea - Área principal de video (flexible) */}
|
||||
<div className="flex-1 min-h-0 bg-gradient-to-br from-gray-900 to-gray-800 rounded-lg overflow-hidden relative">
|
||||
<StudioVideoArea isDemoMode={true} layout={layout} mode={mode} />
|
||||
</div>
|
||||
|
||||
{/* Área inferior - Barra de herramientas estilo StreamYard */}
|
||||
<div className="flex-none flex items-center justify-center gap-3 bg-gray-800/50 backdrop-blur-sm px-4 py-3 rounded-lg">
|
||||
{/* Botones de participantes */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Avatar del presentador */}
|
||||
<div className="relative group">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform">
|
||||
<span className="text-white text-sm font-bold">{userName?.charAt(0)?.toUpperCase() || 'U'}</span>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 bg-red-500 rounded-full border-2 border-gray-800"></div>
|
||||
</div>
|
||||
|
||||
{/* Slots de invitados (5 espacios) */}
|
||||
{[1, 2, 3, 4, 5].map((slot) => (
|
||||
<button
|
||||
key={slot}
|
||||
className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group"
|
||||
title={`Invitar participante ${slot}`}
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Separador */}
|
||||
<div className="w-px h-8 bg-gray-600"></div>
|
||||
|
||||
{/* Botón Presentar o Invitar */}
|
||||
<button className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
<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>
|
||||
<span className="text-sm">Presentar o invitar</span>
|
||||
</button>
|
||||
|
||||
{/* Separador */}
|
||||
<div className="w-px h-8 bg-gray-600"></div>
|
||||
|
||||
{/* Herramientas adicionales */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Botón editar */}
|
||||
<button className="w-10 h-10 rounded-lg bg-blue-600 hover:bg-blue-700 flex items-center justify-center transition-colors group" title="Editar">
|
||||
<svg className="w-5 h-5 text-white" 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>
|
||||
</button>
|
||||
|
||||
{/* Botón añadir */}
|
||||
<button className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group" title="Añadir elemento">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Botón configuración */}
|
||||
<button className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group" title="Configuración">
|
||||
<svg className="w-5 h-5 text-white" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
|
||||
showRightPanel ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{ right: '80px' }}
|
||||
>
|
||||
<StudioRightPanel
|
||||
roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string}
|
||||
activeTab={activeRightTab}
|
||||
onChangeTab={(t) => setActiveRightTab(t)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Tabs Column externo (modo demo): permanece visible incluso cuando el panel se oculta */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-30 pointer-events-auto"
|
||||
style={{ right: '0px' }}
|
||||
>
|
||||
<div className="h-full flex items-start">
|
||||
<TabsColumn activeTab={activeRightTab} onChangeTab={(t) => setActiveRightTab(t)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botón toggle derecho (se mueve con el panel) */}
|
||||
<button
|
||||
onClick={() => setShowRightPanel(!showRightPanel)}
|
||||
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
|
||||
style={{ right: showRightPanel ? '400px' : '80px' }}
|
||||
title={showRightPanel ? 'Ocultar panel derecho' : 'Mostrar panel derecho'}
|
||||
>
|
||||
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(-1)' }}>
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
|
||||
<path d="M0.5 12C0.5 5.64873 5.64873 0.5 12 0.5H15.5V100.5H12C5.64873 100.5 0.5 95.3513 0.5 89V12Z" stroke="#1B1F291A"/>
|
||||
<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.5L6 57"}
|
||||
stroke="#1B1F29"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controles inferiores */}
|
||||
<StudioControls
|
||||
onOpenPresentation={handleOpenPresentationPanel}
|
||||
onShareScreen={handleShareScreen}
|
||||
sharedPresentation={sharedPresentation}
|
||||
onClearPresentation={() => setSharedPresentation(null)}
|
||||
layout={layout}
|
||||
mode={mode}
|
||||
onChangeLayout={(l: 'grid' | 'focus') => setLayout(l)}
|
||||
onChangeMode={(m: 'video' | 'audio') => setMode(m)}
|
||||
/>
|
||||
{/* Presentation Panel modal */}
|
||||
{presentationOpen && (
|
||||
<div className="fixed right-6 bottom-24 z-50">
|
||||
<PresentationPanel onClose={handleClosePresentationPanel} onShareScreen={handleShareScreen} onShareFile={handleShareFile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shared presentation overlay */}
|
||||
{sharedPresentation && (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-80">
|
||||
<div className="max-w-6xl max-h-[80vh] w-full p-4">
|
||||
<div className="bg-gray-900 p-2 rounded">
|
||||
{sharedPresentation.type === 'file' ? (
|
||||
// Try to guess type by URL extension
|
||||
(sharedPresentation.url && sharedPresentation.url.endsWith('.pdf')) ? (
|
||||
<iframe src={sharedPresentation.url} className="w-full h-[70vh] border-0" />
|
||||
) : (sharedPresentation.url && sharedPresentation.url.match(/\.(mp4|webm|ogg)$/i)) ? (
|
||||
<video src={sharedPresentation.url} controls className="w-full h-[70vh] bg-black" />
|
||||
) : (
|
||||
<img src={sharedPresentation.url || ''} alt="presentation" className="w-full h-[70vh] object-contain" />
|
||||
)
|
||||
) : (
|
||||
// screen share is a blob URL
|
||||
<video src={sharedPresentation.url} autoPlay playsInline controls className="w-full h-[70vh] bg-black" />
|
||||
)}
|
||||
<div className="mt-2 text-right">
|
||||
<button onClick={() => setSharedPresentation(null)} className="px-4 py-2 bg-red-600 text-white rounded">Cerrar presentación</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Renderizar con LiveKit (modo normal)
|
||||
|
||||
|
||||
return (
|
||||
<LiveKitRoom
|
||||
video={true}
|
||||
audio={true}
|
||||
token={token}
|
||||
serverUrl={serverUrl}
|
||||
onDisconnected={() => console.log('[LiveKit] Desconectado.')}
|
||||
onError={(e) => console.error('[LiveKit] Error de conexión:', e)}
|
||||
data-lk-theme="default"
|
||||
className="studio-container"
|
||||
>
|
||||
<div className="flex flex-col h-screen bg-gray-900">
|
||||
{/* Header superior */}
|
||||
<StudioHeader roomName={roomName} userName={userName} />
|
||||
|
||||
{/* Contenido principal */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{/* Sidebar izquierdo - Escenas (posición absoluta izquierda) */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
|
||||
showLeftPanel ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<StudioLeftSidebar />
|
||||
</div>
|
||||
|
||||
{/* Botón toggle izquierdo (en el borde derecho del panel izquierdo) */}
|
||||
<button
|
||||
onClick={() => setShowLeftPanel(!showLeftPanel)}
|
||||
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
|
||||
style={{ left: showLeftPanel ? '256px' : '0px' }}
|
||||
title={showLeftPanel ? 'Ocultar panel izquierdo' : 'Mostrar panel izquierdo'}
|
||||
>
|
||||
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(-1)' }}>
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
|
||||
<path d="M0.5 12C0.5 5.64873 5.64873 0.5 12 0.5H15.5V100.5H12C5.64873 100.5 0.5 95.3513 0.5 89V12Z" stroke="#1B1F291A"/>
|
||||
<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"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Contenedor central - Área entre paneles laterales */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
left: showLeftPanel ? '256px' : '0px',
|
||||
right: showRightPanel ? '400px' : '80px'
|
||||
}}
|
||||
>
|
||||
{/* Wrapper interno con padding y espacio para UI adicional */}
|
||||
<div className="w-full h-full flex flex-col p-4 gap-3">
|
||||
{/* Área superior - Controles de layout y calidad */}
|
||||
<div className="flex-none flex items-center justify-between gap-4">
|
||||
{/* Indicador de calidad */}
|
||||
<div className="flex items-center gap-2 bg-gray-800 px-3 py-1.5 rounded-lg">
|
||||
<span className="text-white text-sm font-semibold">720p</span>
|
||||
</div>
|
||||
|
||||
{/* Controles de layout (Grid/Focus) */}
|
||||
<div className="flex items-center gap-2 bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setLayout('grid')}
|
||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
layout === 'grid'
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Vista de cuadrícula"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="7" height="7" strokeWidth="2" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" strokeWidth="2" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" strokeWidth="2" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" strokeWidth="2" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLayout('focus')}
|
||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
layout === 'focus'
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Vista enfocada"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="12" strokeWidth="2" rx="1"/>
|
||||
<rect x="4" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
|
||||
<rect x="10" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
|
||||
<rect x="16" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Branding - Logo AvanzaCast */}
|
||||
<div className="flex items-center gap-2 text-gray-400 text-xs ml-auto">
|
||||
<span>Producido con</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-6 h-6 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="text-white font-semibold">AvanzaCast</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* StudioVideoArea - Área principal de video (flexible) */}
|
||||
<div className="flex-1 min-h-0 bg-gradient-to-br from-gray-900 to-gray-800 rounded-lg overflow-hidden relative">
|
||||
<StudioVideoArea isDemoMode={false} />
|
||||
</div>
|
||||
|
||||
{/* Área inferior - Barra de herramientas estilo StreamYard */}
|
||||
<div className="flex-none flex items-center justify-center gap-3 bg-gray-800/50 backdrop-blur-sm px-4 py-3 rounded-lg">
|
||||
{/* Botones de participantes */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Avatar del presentador */}
|
||||
<div className="relative group">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform">
|
||||
<span className="text-white text-sm font-bold">{userName?.charAt(0)?.toUpperCase() || 'U'}</span>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 bg-red-500 rounded-full border-2 border-gray-800"></div>
|
||||
</div>
|
||||
|
||||
{/* Slots de invitados (5 espacios) */}
|
||||
{[1, 2, 3, 4, 5].map((slot) => (
|
||||
<button
|
||||
key={slot}
|
||||
className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group"
|
||||
title={`Invitar participante ${slot}`}
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Separador */}
|
||||
<div className="w-px h-8 bg-gray-600"></div>
|
||||
|
||||
{/* Botón Presentar o Invitar */}
|
||||
<button className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
<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>
|
||||
<span className="text-sm">Presentar o invitar</span>
|
||||
</button>
|
||||
|
||||
{/* Separador */}
|
||||
<div className="w-px h-8 bg-gray-600"></div>
|
||||
|
||||
{/* Herramientas adicionales */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Botón editar */}
|
||||
<button className="w-10 h-10 rounded-lg bg-blue-600 hover:bg-blue-700 flex items-center justify-center transition-colors group" title="Editar">
|
||||
<svg className="w-5 h-5 text-white" 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>
|
||||
</button>
|
||||
|
||||
{/* Botón añadir */}
|
||||
<button className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group" title="Añadir elemento">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Botón configuración */}
|
||||
<button className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group" title="Configuración">
|
||||
<svg className="w-5 h-5 text-white" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
|
||||
showRightPanel ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{ right: '80px' }}
|
||||
>
|
||||
<StudioRightPanel
|
||||
roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string}
|
||||
activeTab={activeRightTab}
|
||||
onChangeTab={(t) => setActiveRightTab(t)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Tabs Column externo: permanece visible incluso cuando el panel se oculta */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 z-30 pointer-events-auto"
|
||||
style={{ right: '0px' }}
|
||||
>
|
||||
<div className="h-full flex items-start">
|
||||
<TabsColumn activeTab={activeRightTab} onChangeTab={(t) => setActiveRightTab(t)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botón toggle derecho (se mueve con el panel) */}
|
||||
<button
|
||||
onClick={() => setShowRightPanel(!showRightPanel)}
|
||||
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
|
||||
style={{ right: showRightPanel ? '400px' : '80px' }}
|
||||
title={showRightPanel ? 'Ocultar panel derecho' : 'Mostrar panel derecho'}
|
||||
>
|
||||
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(1)' }}>
|
||||
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
|
||||
<path d="M0.5 12C0.5 5.64873 5.64873 0.5 12 0.5H15.5V100.5H12C5.64873 100.5 0.5 95.3513 0.5 89V12Z" stroke="#1B1F291A"/>
|
||||
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
|
||||
<path
|
||||
d={showRightPanel ? "M6 44L10 50.5L6 57" : "M10 44L6 50.5L10 57"}
|
||||
stroke="#FFF"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controles inferiores */}
|
||||
<StudioControls
|
||||
onOpenPresentation={handleOpenPresentationPanel}
|
||||
onShareScreen={handleShareScreen}
|
||||
sharedPresentation={sharedPresentation}
|
||||
onClearPresentation={() => setSharedPresentation(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Renderizador de audio de la sala */}
|
||||
<RoomAudioRenderer />
|
||||
</LiveKitRoom>
|
||||
)
|
||||
}
|
||||
|
||||
export default Studio
|
||||
|
||||
@ -1,167 +0,0 @@
|
||||
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,36 +0,0 @@
|
||||
interface StudioHeaderProps {
|
||||
roomName: string
|
||||
userName: string
|
||||
}
|
||||
|
||||
function StudioHeader({ roomName, userName }: StudioHeaderProps) {
|
||||
return (
|
||||
<div className="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-4">
|
||||
<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="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">
|
||||
<button className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white text-sm rounded transition-colors">
|
||||
Configuración
|
||||
</button>
|
||||
<button className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded transition-colors">
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudioHeader
|
||||
@ -1,46 +0,0 @@
|
||||
function StudioLeftSidebar() {
|
||||
return (
|
||||
<div className="w-64 h-full bg-gray-800 border-r border-gray-700 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
<h3 className="text-white font-semibold mb-4">Participantes</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="p-3 bg-gray-700 rounded hover:bg-gray-600 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs font-bold">TÚ</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">Tú (Anfitrión)</p>
|
||||
<p className="text-gray-400 text-xs">En vivo</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-700">
|
||||
<h3 className="text-white font-semibold mb-4">Fuentes</h3>
|
||||
<div className="space-y-2">
|
||||
<button className="w-full p-3 bg-gray-700 hover:bg-gray-600 rounded text-left transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<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="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>
|
||||
<span className="text-white text-sm">Cámara</span>
|
||||
</div>
|
||||
</button>
|
||||
<button className="w-full p-3 bg-gray-700 hover:bg-gray-600 rounded text-left transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudioLeftSidebar
|
||||
@ -1,519 +0,0 @@
|
||||
export type TabType = 'comments' | 'banners' | 'brand' | 'style' | 'notes' | 'participants' | 'chat'
|
||||
|
||||
interface TabsColumnProps {
|
||||
activeTab: TabType
|
||||
onChangeTab: (tab: TabType) => void
|
||||
onTogglePanel?: () => void
|
||||
isCollapsed?: boolean
|
||||
}
|
||||
|
||||
export function TabsColumn({ activeTab, onChangeTab, onTogglePanel, isCollapsed }: TabsColumnProps) {
|
||||
const tabs: { id: TabType; icon: JSX.Element; label: string }[] = [
|
||||
{
|
||||
id: 'comments',
|
||||
label: 'Comentarios',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'banners',
|
||||
label: 'Banners',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'brand',
|
||||
label: 'Activos multimedia',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'style',
|
||||
label: 'Estilo',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notas',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'participants',
|
||||
label: 'Personas',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="w-20 h-full bg-gray-900 flex flex-col items-center py-2 gap-1 border-l-2 border-gray-700">
|
||||
{/* Botón de colapso en la parte superior */}
|
||||
{onTogglePanel && (
|
||||
<button
|
||||
onClick={onTogglePanel}
|
||||
className="flex flex-col items-center justify-center w-16 h-12 rounded-lg transition-all text-gray-400 hover:bg-gray-800 hover:text-white mb-2"
|
||||
title={isCollapsed ? "Expandir panel" : "Colapsar panel"}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={isCollapsed ? "M15 19l-7-7 7-7" : "M9 5l7 7-7 7"}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onChangeTab(tab.id)}
|
||||
className={`flex flex-col items-center justify-center w-16 h-16 rounded-lg transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
title={tab.label}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="text-[10px] mt-1 leading-tight text-center">{tab.label.split(' ')[0]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StudioRightPanelProps {
|
||||
activeTab: TabType
|
||||
onChangeTab: (tab: TabType) => void
|
||||
}
|
||||
|
||||
function StudioRightPanel({ activeTab }: StudioRightPanelProps) {
|
||||
return (
|
||||
<div className="w-96 bg-gray-900 h-full overflow-y-auto border-r-2 border-gray-700">
|
||||
<div className="p-6">
|
||||
{activeTab === 'comments' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-white font-semibold text-lg">Comentarios</h3>
|
||||
<button className="text-gray-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-2 mb-3">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-xs font-bold">S</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium mb-1">Streamford</p>
|
||||
<p className="text-gray-400 text-sm">Personaliza las características en vivo como este es un ejemplo. Haz clic en un comando para mostrarlo en la pantalla.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="flex items-center justify-center gap-2 w-full py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors">
|
||||
<svg className="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<span className="text-white text-sm font-medium">Mostrar</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Agrega un destino para publicar comentarios."
|
||||
className="w-full px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-blue-500 pr-10"
|
||||
/>
|
||||
<button className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-blue-500">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'banners' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="text-gray-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h3 className="text-white font-semibold text-lg">Banners de ejemplo</h3>
|
||||
</div>
|
||||
<button className="text-gray-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors cursor-pointer">
|
||||
<p className="text-white text-sm mb-2">Este es un ejemplo de banner. Haz clic en un banner para mostrarlo en la pantalla.</p>
|
||||
<p className="text-gray-400 text-xs">Utiliza banners para resumir los temas de los que estás hablando y mostrar llamadas a la acción</p>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors cursor-pointer">
|
||||
<p className="text-white text-sm font-medium">Banner</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="flex items-center gap-2 text-gray-400 hover:text-white">
|
||||
<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>
|
||||
<span className="text-sm">Crear un banner</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'brand' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-pink-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-bold">M</span>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-lg">Marca 1</h3>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<button className="text-gray-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Logo */}
|
||||
<div className="border-b border-gray-800 pb-4">
|
||||
<button className="flex items-center justify-between w-full text-left group">
|
||||
<span className="text-white font-medium">Logo</span>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Superposición */}
|
||||
<div className="border-b border-gray-800 pb-4">
|
||||
<button className="flex items-center justify-between w-full text-left group">
|
||||
<span className="text-white font-medium">Superposición</span>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Código QR */}
|
||||
<div className="border-b border-gray-800 pb-4">
|
||||
<button className="flex items-center justify-between w-full text-left group">
|
||||
<span className="text-white font-medium">Código QR</span>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Clips de video */}
|
||||
<div className="border-b border-gray-800 pb-4">
|
||||
<button className="flex items-center justify-between w-full text-left group">
|
||||
<span className="text-white font-medium">Clips de video</span>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fondo */}
|
||||
<div className="border-b border-gray-800 pb-4">
|
||||
<button className="flex items-center justify-between w-full text-left group">
|
||||
<span className="text-white font-medium">Fondo</span>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sonidos */}
|
||||
<div className="border-b border-gray-800 pb-4">
|
||||
<button className="flex items-center justify-between w-full text-left group">
|
||||
<span className="text-white font-medium">Sonidos</span>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Música de fondo */}
|
||||
<div className="pb-4">
|
||||
<button className="flex items-center justify-between w-full text-left group">
|
||||
<span className="text-white font-medium">Música de fondo</span>
|
||||
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'style' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-pink-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-bold">M</span>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-lg">Marca 1</h3>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<button className="text-gray-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ajustes preestablecidos */}
|
||||
<div className="mb-6">
|
||||
<button className="flex items-center justify-between w-full text-left mb-3">
|
||||
<span className="text-white font-medium">Ajustes preestablecidos</span>
|
||||
<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="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
<div className="flex-shrink-0 w-24 h-24 bg-gray-800 rounded-lg border-2 border-blue-500 flex items-center justify-center cursor-pointer">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 bg-gray-700 rounded-full mx-auto mb-1"></div>
|
||||
<div className="text-xs text-gray-400">Preset 1</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-24 h-24 bg-gray-800 rounded-lg border-2 border-transparent hover:border-gray-600 flex items-center justify-center cursor-pointer">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 bg-yellow-500 rounded-full mx-auto mb-1"></div>
|
||||
<div className="text-xs text-gray-400">Preset 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-24 h-24 bg-gray-800 rounded-lg border-2 border-transparent hover:border-gray-600 flex items-center justify-center cursor-pointer">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 bg-red-500 rounded-full mx-auto mb-1"></div>
|
||||
<div className="text-xs text-gray-400">Preset 3</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color de la marca */}
|
||||
<div className="mb-6">
|
||||
<button className="flex items-center justify-between w-full text-left mb-3">
|
||||
<span className="text-white font-medium">Color de la marca</span>
|
||||
<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="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-yellow-500 rounded-lg border-2 border-gray-700"></div>
|
||||
<input
|
||||
type="text"
|
||||
value="#ffc42c"
|
||||
className="flex-1 px-3 py-2 bg-gray-800 text-white rounded border border-gray-700 focus:outline-none focus:border-blue-500"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tema */}
|
||||
<div className="mb-6">
|
||||
<button className="flex items-center justify-between w-full text-left mb-3">
|
||||
<span className="text-white font-medium">Tema</span>
|
||||
<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="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button className="p-3 bg-yellow-500 text-gray-900 rounded-lg font-medium hover:bg-yellow-400 transition-colors">
|
||||
Bubble
|
||||
</button>
|
||||
<button className="p-3 bg-gray-800 text-white rounded-lg font-medium hover:bg-gray-700 transition-colors border border-yellow-500">
|
||||
Classic
|
||||
</button>
|
||||
<button className="p-3 bg-gray-800 text-white rounded-lg font-medium hover:bg-gray-700 transition-colors">
|
||||
Minimal
|
||||
</button>
|
||||
<button className="p-3 bg-gray-900 text-white rounded-lg font-medium hover:bg-gray-800 transition-colors border border-yellow-500">
|
||||
Block
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mostrar nombres */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">Mostrar nombres</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button className="relative inline-flex h-6 w-11 items-center rounded-full bg-blue-600">
|
||||
<span className="translate-x-6 inline-block h-4 w-4 transform rounded-full bg-white transition"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notes' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white font-semibold text-lg">Notas</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded font-bold">
|
||||
<span>B</span>
|
||||
</button>
|
||||
<button className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded italic">
|
||||
<span>I</span>
|
||||
</button>
|
||||
<button className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded underline">
|
||||
<span>U</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-white font-medium text-sm">Teleprompter</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<svg className="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<div className="ml-auto">
|
||||
<button className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-700">
|
||||
<span className="translate-x-1 inline-block h-4 w-4 transform rounded-full bg-white transition"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="w-full h-64 px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-blue-500 resize-none"
|
||||
placeholder="Agrega tus notas aquí..."
|
||||
></textarea>
|
||||
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
0 palabras • 0 caracteres
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'participants' && (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-white font-semibold text-lg mb-4">Comparte este enlace para invitar a los invitados en vivo</h3>
|
||||
<button className="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="font-medium">Copiar enlace de invitación</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-4">
|
||||
<button className="flex items-center justify-between w-full text-left mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-white font-medium">Escenario • 1 persona</span>
|
||||
</div>
|
||||
<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="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white font-bold">CM</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">Cesar Mendivil</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-blue-400">Anfitrión</span>
|
||||
<span className="text-xs text-gray-500">480p</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-1.5 text-gray-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-white">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="flex-1 h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full w-2/3 bg-green-500"></div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<input type="checkbox" className="rounded" defaultChecked />
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudioRightPanel
|
||||
@ -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
|
||||
@ -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
|
||||
@ -1,32 +0,0 @@
|
||||
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
|
||||
@ -1,151 +0,0 @@
|
||||
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
|
||||
@ -1,52 +0,0 @@
|
||||
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
|
||||
@ -1,178 +0,0 @@
|
||||
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
|
||||
@ -1,5 +0,0 @@
|
||||
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'
|
||||
102
packages/studio-panel/src/components/ui/DestinationModal.tsx
Normal file
102
packages/studio-panel/src/components/ui/DestinationModal.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useDestinations, Destination } from '../../hooks/useDestinations'
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'youtube', label: 'YouTube', color: 'bg-red-600' },
|
||||
{ id: 'twitch', label: 'Twitch', color: 'bg-purple-700' },
|
||||
{ id: 'facebook', label: 'Facebook', color: 'bg-blue-600' },
|
||||
{ id: 'linkedin', label: 'LinkedIn', color: 'bg-indigo-700' },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
editing?: Destination | null
|
||||
onSaved?: (d: Destination) => void
|
||||
}
|
||||
|
||||
const DestinationModal: React.FC<Props> = ({ open, onClose, editing = null, onSaved }) => {
|
||||
const { addDestination, updateDestination } = useDestinations()
|
||||
const [platform, setPlatform] = useState(PLATFORMS[0].id)
|
||||
const [label, setLabel] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setPlatform(editing.platform)
|
||||
setLabel(editing.label)
|
||||
setUrl(editing.url || '')
|
||||
} else {
|
||||
setPlatform(PLATFORMS[0].id)
|
||||
setLabel('')
|
||||
setUrl('')
|
||||
}
|
||||
setError(null)
|
||||
}, [editing, open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const validate = () => {
|
||||
if (!label || label.trim().length === 0) {
|
||||
setError('La etiqueta es obligatoria')
|
||||
return false
|
||||
}
|
||||
if (url && !/^https?:\/\//.test(url)) {
|
||||
setError('La URL debe comenzar con http:// o https://')
|
||||
return false
|
||||
}
|
||||
setError(null)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!validate()) return
|
||||
if (editing) {
|
||||
updateDestination(editing.id, { platform, label, url: url || undefined })
|
||||
onSaved && onSaved({ ...editing, platform, label, url: url || undefined })
|
||||
} else {
|
||||
const newD = addDestination({ platform, label, url: url || undefined })
|
||||
onSaved && onSaved(newD)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md bg-white dark:bg-gray-900 rounded-md p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">{editing ? 'Editar destino' : 'Agregar destino'}</h3>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">Plataforma</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
{PLATFORMS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setPlatform(p.id)}
|
||||
className={`px-3 py-2 rounded-md border ${platform === p.id ? 'border-gray-700' : 'border-transparent'} flex-1 text-sm`}
|
||||
>
|
||||
<span className={`${p.color} inline-block w-4 h-4 rounded-full mr-2`} />
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">Etiqueta</label>
|
||||
<input value={label} onChange={(e) => setLabel(e.target.value)} className="px-3 py-2 rounded-md bg-gray-100 dark:bg-gray-800" />
|
||||
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">URL (opcional)</label>
|
||||
<input value={url} onChange={(e) => setUrl(e.target.value)} className="px-3 py-2 rounded-md bg-gray-100 dark:bg-gray-800" />
|
||||
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<button onClick={onClose} className="px-3 py-2 rounded-md bg-gray-200 hover:bg-gray-300">Cerrar</button>
|
||||
<button onClick={handleAdd} className="px-3 py-2 rounded-md bg-[#2563eb] text-white">{editing ? 'Guardar' : 'Agregar'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DestinationModal
|
||||
89
packages/studio-panel/src/components/ui/Header.tsx
Normal file
89
packages/studio-panel/src/components/ui/Header.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react'
|
||||
import DestinationModal from './DestinationModal'
|
||||
import { useDestinations, Destination } from '../../hooks/useDestinations'
|
||||
|
||||
const PlatformBadge: React.FC<{ color: string; children: React.ReactNode }> = ({ color, children }) => (
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold ${color}`}>{children}</span>
|
||||
)
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Destination | null>(null)
|
||||
const { destinations, removeDestination } = useDestinations()
|
||||
|
||||
const handleEdit = (d: Destination) => {
|
||||
setEditing(d)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (!confirm('¿Eliminar destino?')) return
|
||||
removeDestination(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="w-full bg-[#0b1220] text-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-sky-600 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 12c3-6 9-8 13-8 0 4-2 8-6 10 4 0 6 2 6 6-6 0-10-3-13-8z" strokeWidth="1.2" stroke="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Transmision</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setOpen(true)} className="relative flex items-center gap-3 bg-[#2563eb] hover:bg-[#1e40af] text-white px-4 py-2 rounded-md text-sm font-medium shadow">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 5v14M5 12h14" strokeWidth={2} stroke="white" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Agregar destino</span>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<PlatformBadge color="bg-red-600">▶</PlatformBadge>
|
||||
<PlatformBadge color="bg-purple-700">𝕋</PlatformBadge>
|
||||
<PlatformBadge color="bg-blue-600">f</PlatformBadge>
|
||||
<PlatformBadge color="bg-indigo-700">in</PlatformBadge>
|
||||
</div>
|
||||
{destinations.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">{destinations.length}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button className="bg-transparent border border-white/20 hover:bg-white/5 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||
Grabar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="w-full bg-white/5 text-white p-3">
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{destinations.map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-2 bg-white/5 px-3 py-2 rounded-md">
|
||||
<div className="text-sm">{d.label}</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleEdit(d)} className="text-xs px-2 py-1 bg-blue-600 rounded text-white">Editar</button>
|
||||
<button onClick={() => handleDelete(d.id)} className="text-xs px-2 py-1 bg-red-600 rounded text-white">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DestinationModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
editing={editing}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
74
packages/studio-panel/src/components/ui/LeftSidePanel.tsx
Normal file
74
packages/studio-panel/src/components/ui/LeftSidePanel.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
|
||||
const scenesMock = [
|
||||
{ id: 's1', title: 'Demo scene 1' },
|
||||
{ id: 's2', title: 'Demo scene 2' },
|
||||
{ id: 's3', title: 'Discusión grupal' },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
const LeftSidePanel: React.FC<Props> = ({ open, onToggle }) => {
|
||||
return (
|
||||
<>
|
||||
{/* Panel */}
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={`fixed top-12 inset-y-0 left-0 z-40 w-64 transform transition-transform duration-300 ease-in-out ${
|
||||
open ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<aside className="h-full bg-neutral-900 border-r border-gray-800 text-white flex flex-col">
|
||||
<div className="px-4 py-3 border-b border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Escenas</h3>
|
||||
<span className="text-xs bg-purple-600 px-2 py-0.5 rounded">BETA</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">Mis Escenas</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 overflow-y-auto flex-1 space-y-3">
|
||||
<button className="w-full flex items-center justify-center border-2 border-dashed border-gray-700 rounded-md p-3 text-sm text-gray-300">
|
||||
Establecer video de introducción
|
||||
</button>
|
||||
|
||||
<div className="space-y-3">
|
||||
{scenesMock.map((s) => (
|
||||
<div key={s.id} className="bg-neutral-800 rounded-md overflow-hidden">
|
||||
<div className="h-20 bg-neutral-700/40 flex items-center justify-center">{/* thumbnail */}</div>
|
||||
<div className="px-2 py-2 text-sm">{s.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="mt-2 w-full bg-white/5 text-white px-3 py-2 rounded-md text-sm">+ Nueva escena</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-gray-800 text-xs text-gray-400">Establecer video de cierre</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Toggle tab */}
|
||||
<button
|
||||
aria-label={open ? 'Cerrar panel' : 'Abrir panel'}
|
||||
onClick={onToggle}
|
||||
className={`fixed z-50 h-12 w-8 flex items-center justify-center rounded-r-md bg-[#0b1220] text-white shadow top-22 transform transition-left duration-300 ${
|
||||
open ? 'left-64' : 'left-0'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
{open ? (
|
||||
<path d="M15 18l-6-6 6-6" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
) : (
|
||||
<path d="M9 6l6 6-6 6" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeftSidePanel
|
||||
@ -1,129 +0,0 @@
|
||||
// Configuración para modo demo/desarrollo
|
||||
export const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === 'true' || false
|
||||
|
||||
// Token simulado para desarrollo (cuando LiveKit no está disponible)
|
||||
export const DEMO_TOKEN = 'demo-token-for-development'
|
||||
|
||||
// Configuración simulada de LiveKit
|
||||
export const DEMO_LIVEKIT_CONFIG = {
|
||||
serverUrl: 'wss://demo.livekit.cloud',
|
||||
token: DEMO_TOKEN,
|
||||
}
|
||||
|
||||
// Participantes simulados para modo demo
|
||||
export const DEMO_PARTICIPANTS = [
|
||||
{
|
||||
id: 'local-user',
|
||||
identity: 'Usuario Local',
|
||||
name: 'Tú',
|
||||
isSpeaking: false,
|
||||
isCameraEnabled: true,
|
||||
isMicrophoneEnabled: true,
|
||||
isScreenShareEnabled: false,
|
||||
isLocal: true,
|
||||
},
|
||||
{
|
||||
id: 'guest-1',
|
||||
identity: 'guest-1',
|
||||
name: 'Invitado 1',
|
||||
isSpeaking: true,
|
||||
isCameraEnabled: true,
|
||||
isMicrophoneEnabled: true,
|
||||
isScreenShareEnabled: false,
|
||||
isLocal: false,
|
||||
},
|
||||
{
|
||||
id: 'guest-2',
|
||||
identity: 'guest-2',
|
||||
name: 'Invitado 2',
|
||||
isSpeaking: false,
|
||||
isCameraEnabled: false,
|
||||
isMicrophoneEnabled: true,
|
||||
isScreenShareEnabled: false,
|
||||
isLocal: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Escenas predefinidas para demo
|
||||
export const DEMO_SCENES = [
|
||||
{
|
||||
id: 'scene-1',
|
||||
name: 'Escena Principal',
|
||||
thumbnail: 'https://via.placeholder.com/160x90/1a1a24/ec4899?text=Escena+1',
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
name: 'Presentación',
|
||||
thumbnail: 'https://via.placeholder.com/160x90/1a1a24/3b82f6?text=Escena+2',
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
id: 'scene-3',
|
||||
name: 'Pantalla compartida',
|
||||
thumbnail: 'https://via.placeholder.com/160x90/1a1a24/10b981?text=Escena+3',
|
||||
active: false,
|
||||
},
|
||||
]
|
||||
|
||||
// Temas de color para personalización
|
||||
export const COLOR_THEMES = [
|
||||
{ id: 'rosa', name: 'Rosa', primary: '#ec4899', secondary: '#be185d' },
|
||||
{ id: 'azul', name: 'Azul', primary: '#3b82f6', secondary: '#1e40af' },
|
||||
{ id: 'verde', name: 'Verde', primary: '#10b981', secondary: '#047857' },
|
||||
{ id: 'morado', name: 'Morado', primary: '#a855f7', secondary: '#7e22ce' },
|
||||
{ id: 'naranja', name: 'Naranja', primary: '#f97316', secondary: '#c2410c' },
|
||||
{ id: 'rojo', name: 'Rojo', primary: '#ef4444', secondary: '#b91c1c' },
|
||||
]
|
||||
|
||||
// Overlays de ejemplo
|
||||
export const DEMO_OVERLAYS = [
|
||||
{
|
||||
id: 'overlay-1',
|
||||
name: 'Lower Third',
|
||||
thumbnail: 'https://via.placeholder.com/120x68/1a1a24/ec4899?text=Lower+Third',
|
||||
type: 'lower-third',
|
||||
},
|
||||
{
|
||||
id: 'overlay-2',
|
||||
name: 'Logo Corner',
|
||||
thumbnail: 'https://via.placeholder.com/120x68/1a1a24/3b82f6?text=Logo',
|
||||
type: 'logo',
|
||||
},
|
||||
{
|
||||
id: 'overlay-3',
|
||||
name: 'Banner Superior',
|
||||
thumbnail: 'https://via.placeholder.com/120x68/1a1a24/10b981?text=Banner',
|
||||
type: 'banner',
|
||||
},
|
||||
]
|
||||
|
||||
// Fondos de ejemplo
|
||||
export const DEMO_BACKGROUNDS = [
|
||||
{
|
||||
id: 'bg-1',
|
||||
name: 'Gradient Rosa',
|
||||
thumbnail: 'https://via.placeholder.com/120x68/ec4899/be185d?text=Gradient',
|
||||
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
|
||||
},
|
||||
{
|
||||
id: 'bg-2',
|
||||
name: 'Gradient Azul',
|
||||
thumbnail: 'https://via.placeholder.com/120x68/3b82f6/1e40af?text=Gradient',
|
||||
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
|
||||
},
|
||||
{
|
||||
id: 'bg-3',
|
||||
name: 'Gradient Verde',
|
||||
thumbnail: 'https://via.placeholder.com/120x68/10b981/047857?text=Gradient',
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #047857 100%)',
|
||||
},
|
||||
]
|
||||
|
||||
// Efectos de sonido de ejemplo
|
||||
export const DEMO_SOUNDS = [
|
||||
{ id: 'sound-1', name: 'Aplauso', icon: '👏', file: '/sounds/applause.mp3' },
|
||||
{ id: 'sound-2', name: 'Risa', icon: '😄', file: '/sounds/laugh.mp3' },
|
||||
{ id: 'sound-3', name: 'Campana', icon: '🔔', file: '/sounds/bell.mp3' },
|
||||
{ id: 'sound-4', name: 'Intro', icon: '🎵', file: '/sounds/intro.mp3' },
|
||||
]
|
||||
@ -1,114 +0,0 @@
|
||||
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
|
||||
}
|
||||
4
packages/studio-panel/src/global.d.ts
vendored
Normal file
4
packages/studio-panel/src/global.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module '*.css'
|
||||
declare module '*.scss'
|
||||
declare module '*.png'
|
||||
declare module '*.svg'
|
||||
45
packages/studio-panel/src/hooks/useDestinations.ts
Normal file
45
packages/studio-panel/src/hooks/useDestinations.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export type Destination = {
|
||||
id: string
|
||||
platform: string
|
||||
label: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'studio_panel_destinations'
|
||||
|
||||
export function useDestinations() {
|
||||
const [destinations, setDestinations] = useState<Destination[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) setDestinations(JSON.parse(raw))
|
||||
} catch (e) {
|
||||
setDestinations([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(destinations))
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, [destinations])
|
||||
|
||||
const addDestination = (d: Omit<Destination, 'id'>) => {
|
||||
const newDest = { ...d, id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` }
|
||||
setDestinations((s) => [newDest, ...s])
|
||||
return newDest
|
||||
}
|
||||
|
||||
const updateDestination = (id: string, patch: Partial<Omit<Destination, 'id'>>) => {
|
||||
setDestinations((s) => s.map((x) => (x.id === id ? { ...x, ...patch } : x)))
|
||||
}
|
||||
|
||||
const removeDestination = (id: string) => setDestinations((s) => s.filter((x) => x.id !== id))
|
||||
|
||||
return { destinations, addDestination, updateDestination, removeDestination }
|
||||
}
|
||||
24
packages/studio-panel/src/hooks/useScene.ts
Normal file
24
packages/studio-panel/src/hooks/useScene.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// Hook minimal de ejemplo para exponer la escena (interface ligera)
|
||||
import { useState } from 'react'
|
||||
import type { SceneConfig } from '../types'
|
||||
|
||||
export function useScene() {
|
||||
const [sceneConfig, setSceneConfig] = useState<SceneConfig>({
|
||||
participantLayout: 'single_speaker',
|
||||
mediaSource: null,
|
||||
overlays: {
|
||||
showLogo: true,
|
||||
showLowerThird: false,
|
||||
lowerThirdText: ''
|
||||
}
|
||||
})
|
||||
|
||||
const applyPreset = (preset: any) => {
|
||||
if (!preset) return
|
||||
setSceneConfig((s) => ({ ...s, ...preset }))
|
||||
}
|
||||
|
||||
const updateOverlays = (overlays: SceneConfig['overlays']) => setSceneConfig((s) => ({ ...s, overlays }))
|
||||
|
||||
return { sceneConfig, applyPreset, updateOverlays }
|
||||
}
|
||||
@ -2,6 +2,12 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
body { @apply bg-neutral-900; }
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: #374151;
|
||||
|
||||
17
packages/studio-panel/src/layouts/StudioLayout.tsx
Normal file
17
packages/studio-panel/src/layouts/StudioLayout.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React, { useState } from 'react'
|
||||
import Header from '../components/ui/Header'
|
||||
import LeftSidePanel from '../components/ui/LeftSidePanel'
|
||||
|
||||
const StudioLayout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
|
||||
const [leftOpen, setLeftOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-900 text-white">
|
||||
<Header />
|
||||
<LeftSidePanel open={leftOpen} onToggle={() => setLeftOpen((v) => !v)} />
|
||||
<main className={`p-4 transition-all duration-300 ${leftOpen ? 'md:ml-64' : 'md:ml-0'}`}>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudioLayout
|
||||
@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
// @ts-ignore - allow importing css as side-effect for Tailwind
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { GridLayout, TrackLoop, ParticipantAudioTile, useTracks } from '@livekit/components-react'
|
||||
|
||||
interface AudioConferenceProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AudioConference: React.FC<AudioConferenceProps> = ({ className }) => {
|
||||
const audioTracks = useTracks([{ source: Track.Source.Microphone, withPlaceholder: true }])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<GridLayout tracks={audioTracks as any}>
|
||||
<TrackLoop tracks={audioTracks as any}>
|
||||
<ParticipantAudioTile />
|
||||
</TrackLoop>
|
||||
</GridLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioConference
|
||||
@ -1,36 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useChat } from '@livekit/components-react'
|
||||
|
||||
interface ChatProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Chat: React.FC<ChatProps> = ({ className }) => {
|
||||
const { chatMessages, send, isSending } = useChat()
|
||||
const [text, setText] = useState('')
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!text) return
|
||||
await send(text)
|
||||
setText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className || ''}`}>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{chatMessages.map((m) => (
|
||||
<div key={m.timestamp} className="mb-2">
|
||||
<div className="text-xs text-gray-400">{m.from?.identity || 'anon'}</div>
|
||||
<div className="text-sm text-white">{m.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-2 border-t border-gray-700 flex gap-2">
|
||||
<input className="flex-1 bg-gray-900 text-white p-2 rounded" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
<button className="bg-pink-500 text-white px-3 py-2 rounded" onClick={sendMessage} disabled={isSending}>Enviar</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Chat
|
||||
@ -1,33 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useLocalParticipant, TrackToggle, StartAudio } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
interface ControlBarProps {
|
||||
onLeave?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ControlBar: React.FC<ControlBarProps> = ({ onLeave, className }) => {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 p-3 bg-gray-800 ${className || ''}`}>
|
||||
<button
|
||||
className="bg-red-600 text-white px-3 py-2 rounded"
|
||||
onClick={() => {
|
||||
if (onLeave) onLeave()
|
||||
}}
|
||||
>
|
||||
Salir
|
||||
</button>
|
||||
<div className="flex gap-2 items-center">
|
||||
<StartAudio label="Permitir audio" />
|
||||
<TrackToggle source={Track.Source.Camera} />
|
||||
<TrackToggle source={Track.Source.Microphone} />
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-gray-300">{localParticipant?.identity}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ControlBar
|
||||
@ -1,34 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useStartVideo, useMediaDevices } from '@livekit/components-react'
|
||||
|
||||
interface PreJoinProps {
|
||||
onJoin?: () => void
|
||||
}
|
||||
|
||||
const PreJoin: React.FC<PreJoinProps> = ({ onJoin }) => {
|
||||
const videoDevices = useMediaDevices({ kind: 'videoinput' })
|
||||
const { mergedProps, canPlayVideo } = useStartVideo({ props: { className: 'bg-gray-700 text-white px-3 py-1 rounded' } })
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-900 rounded-lg">
|
||||
<h3 className="text-white text-lg mb-4">Antes de entrar</h3>
|
||||
<div className="mb-4">
|
||||
<div className="w-full h-48 bg-black rounded-md overflow-hidden flex items-center justify-center text-gray-400">
|
||||
<div>
|
||||
<div className="mb-2">Vista previa no activa</div>
|
||||
<div className="text-xs text-gray-400">Pulsa "Iniciar cámara" para ver la previsualización</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button {...mergedProps} disabled={!canPlayVideo}>Iniciar cámara</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 text-sm text-gray-300">Dispositivos de video detectados: {videoDevices.length}</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="bg-pink-500 text-white px-4 py-2 rounded" onClick={onJoin}>Entrar</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreJoin
|
||||
@ -1,36 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { GridLayout, FocusLayout, ParticipantTile, TrackLoop, useTracks } from '@livekit/components-react'
|
||||
|
||||
interface VideoConferenceProps {
|
||||
layout?: 'grid' | 'focus'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VideoConference: React.FC<VideoConferenceProps> = ({ layout = 'grid', className }) => {
|
||||
// request camera tracks (with placeholders for participants without camera)
|
||||
const cameraTracks = useTracks([{ source: Track.Source.Camera, withPlaceholder: true }])
|
||||
|
||||
if (layout === 'focus') {
|
||||
// FocusLayout accepts a single trackRef (we can pass the first track as default)
|
||||
return (
|
||||
<div className={className}>
|
||||
<FocusLayout trackRef={cameraTracks[0] as any}>
|
||||
{/* children are not required here; FocusLayout will render the passed trackRef */}
|
||||
</FocusLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<GridLayout tracks={cameraTracks as any}>
|
||||
<TrackLoop tracks={cameraTracks as any}>
|
||||
<ParticipantTile />
|
||||
</TrackLoop>
|
||||
</GridLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoConference
|
||||
@ -1,5 +0,0 @@
|
||||
export { default as VideoConference } from './VideoConference'
|
||||
export { default as AudioConference } from './AudioConference'
|
||||
export { default as PreJoin } from './PreJoin'
|
||||
export { default as ControlBar } from './ControlBar'
|
||||
export { default as Chat } from './Chat'
|
||||
18
packages/studio-panel/src/types/index.d.ts
vendored
Normal file
18
packages/studio-panel/src/types/index.d.ts
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
export type ParticipantLayoutType = 'single_speaker' | 'side_by_side' | 'grid_4' | 'grid_6' | 'focus_side' | 'presentation'
|
||||
|
||||
export type MediaSourceType =
|
||||
| { type: 'screen'; stream: MediaStream }
|
||||
| { type: 'file'; url: string }
|
||||
| null
|
||||
|
||||
export type OverlayConfig = {
|
||||
showLogo: boolean
|
||||
showLowerThird: boolean
|
||||
lowerThirdText?: string
|
||||
}
|
||||
|
||||
export type SceneConfig = {
|
||||
participantLayout: ParticipantLayoutType
|
||||
mediaSource: MediaSourceType
|
||||
overlays: OverlayConfig
|
||||
}
|
||||
12
packages/studio-panel/src/vite-env.d.ts
vendored
12
packages/studio-panel/src/vite-env.d.ts
vendored
@ -1,12 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_LIVEKIT_URL: string
|
||||
readonly VITE_LIVEKIT_API_KEY: string
|
||||
readonly VITE_LIVEKIT_API_SECRET: string
|
||||
readonly VITE_DEMO_MODE: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
* Enfocado en multistreaming, broadcasting y grabación en la nube
|
||||
*/
|
||||
|
||||
import Header from '@/components/Header';
|
||||
import StreamingHeroSection from '@/components/StreamingHeroSection';
|
||||
import StreamingStats from '@/components/StreamingStats';
|
||||
import PlatformLogos from '@/components/PlatformLogos';
|
||||
@ -15,6 +16,7 @@ import PricingSection from '@/components/PricingSection';
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-white dark:bg-gray-900">
|
||||
<Header />
|
||||
<StreamingHeroSection />
|
||||
<StreamingStats />
|
||||
<PlatformLogos />
|
||||
|
||||
45
src/components/Header.tsx
Normal file
45
src/components/Header.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
|
||||
const PlatformBadge: React.FC<{ color: string; children: React.ReactNode }> = ({ color, children }) => (
|
||||
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold ${color}`}>{children}</span>
|
||||
)
|
||||
|
||||
const Header: React.FC = () => {
|
||||
return (
|
||||
<header className="w-full bg-[#0b1220] text-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-sky-600 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M3 12c3-6 9-8 13-8 0 4-2 8-6 10 4 0 6 2 6 6-6 0-10-3-13-8z" strokeWidth="1.2" stroke="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Transmision</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="flex items-center gap-3 bg-[#2563eb] hover:bg-[#1e40af] text-white px-4 py-2 rounded-md text-sm font-medium shadow">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M12 5v14M5 12h14" strokeWidth={2} stroke="white" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>Agregar destino</span>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<PlatformBadge color="bg-red-600">▶</PlatformBadge>
|
||||
<PlatformBadge color="bg-purple-700">𝕋</PlatformBadge>
|
||||
<PlatformBadge color="bg-blue-600">f</PlatformBadge>
|
||||
<PlatformBadge color="bg-indigo-700">in</PlatformBadge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="bg-transparent border border-white/20 hover:bg-white/5 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||
Grabar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
Loading…
x
Reference in New Issue
Block a user