feat: Live Chat en TV + página overlay transparente para streaming

- Añade componente LiveChatBox con Realtime de Appwrite (subscribe)
- Integra chat en vivo junto al player en /tv con layout 2/3 + 1/3
- Nueva ruta /tv/overlay: página transparente para browser source en OBS
  con renderizadores de LOWER_THIRD, TICKER y COMMENT reactivos en Realtime
- Exporta 'account' desde appwrite.ts para autenticación cliente
- Incluye scripts setup_live_chat.js y setup_stream_state.js para provisionar
  colecciones live_chat y stream_state en Appwrite
- Ajusta versiones SDK: appwrite@22.4.1 y node-appwrite@22.0.0 (compatibles con servidor 1.8.1)
This commit is contained in:
Carlos (Almeira) 2026-06-20 22:18:29 -05:00
parent 155546f90e
commit 699cfa232d
8 changed files with 352 additions and 14 deletions

16
package-lock.json generated
View File

@ -21,14 +21,14 @@
"@tiptap/starter-kit": "^3.21.0",
"@types/dompurify": "^3.0.5",
"@types/video.js": "^7.3.58",
"appwrite": "^24.1.1",
"appwrite": "^22.4.1",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"isomorphic-dompurify": "^2.20.0",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"node-appwrite": "^23.1.0",
"node-appwrite": "^22.0.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",
@ -3137,9 +3137,9 @@
}
},
"node_modules/appwrite": {
"version": "24.1.1",
"resolved": "https://registry.npmjs.org/appwrite/-/appwrite-24.1.1.tgz",
"integrity": "sha512-6dTKZe059GWyrH1AObfC0KR20vmQ9+wUo3iCRmM4lFofTRMrSzjCEcblpNERXHVKrLlzsr1T4kQweQEgrp89yQ==",
"version": "22.4.1",
"resolved": "https://registry.npmjs.org/appwrite/-/appwrite-22.4.1.tgz",
"integrity": "sha512-NlEgUvSo7A1m+TEHZ8zlWUIuCDtNg3VN6Mbxd6r+1618CiWokig630FpeKQ9/WD5Io92QxuTV/dVzBLPlQghqg==",
"license": "BSD-3-Clause",
"dependencies": {
"json-bigint": "1.0.0"
@ -6494,9 +6494,9 @@
}
},
"node_modules/node-appwrite": {
"version": "23.1.0",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-23.1.0.tgz",
"integrity": "sha512-XYv23uIIrEfAomubIrQZFMo6jbIpCfmFwkvTwwo8zEJM1KTpFnSuXg8mSvtnu1ssSp6NqxVbasSAyQ8XF8cEmg==",
"version": "22.1.3",
"resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-22.1.3.tgz",
"integrity": "sha512-pXxvojqgYY3HAiJAj+P6xb/CXVNsMUd+JXVOLrCCDzqZmpKeMF6OEt4mAoDEyKTnRaixUDHlPrdh7y1G/w3sbw==",
"license": "BSD-3-Clause",
"dependencies": {
"json-bigint": "1.0.0",

View File

@ -23,14 +23,14 @@
"@tiptap/starter-kit": "^3.21.0",
"@types/dompurify": "^3.0.5",
"@types/video.js": "^7.3.58",
"appwrite": "^24.1.1",
"appwrite": "^22.4.1",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"isomorphic-dompurify": "^2.20.0",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"node-appwrite": "^23.1.0",
"node-appwrite": "^22.0.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",

44
setup_live_chat.js Normal file
View File

@ -0,0 +1,44 @@
const { Client, Databases, ID, Permission, Role } = require('node-appwrite');
require('dotenv').config({ path: '.env.local' });
async function setupLiveChat() {
console.log("Configuring Live Chat collection...");
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT)
.setKey(process.env.APPWRITE_API_KEY);
const databases = new Databases(client);
const dbId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE;
try {
const collection = await databases.createCollection(
dbId,
'live_chat',
'Live Chat',
[
Permission.read(Role.any()),
Permission.create(Role.users()), // Logged in users can chat
Permission.update(Role.team('admin')),
Permission.delete(Role.team('admin')),
]
);
console.log("Collection 'live_chat' created.");
// Attributes
await databases.createStringAttribute(dbId, 'live_chat', 'user_id', 50, true);
await databases.createStringAttribute(dbId, 'live_chat', 'user_name', 100, true);
await databases.createStringAttribute(dbId, 'live_chat', 'message', 500, true);
await databases.createDatetimeAttribute(dbId, 'live_chat', 'timestamp', true);
console.log("Attributes created.");
} catch (e) {
if (e.code === 409) {
console.log("Collection already exists.");
} else {
console.error("Error creating collection:", e);
}
}
}
setupLiveChat();

41
setup_stream_state.js Normal file
View File

@ -0,0 +1,41 @@
const { Client, Databases, ID, Permission, Role } = require('node-appwrite');
require('dotenv').config({ path: '.env.local' });
async function setupStreamState() {
console.log("Configuring Stream State collection...");
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT)
.setKey(process.env.APPWRITE_API_KEY);
const databases = new Databases(client);
const dbId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE;
try {
const collection = await databases.createCollection(
dbId,
'stream_state',
'Stream State',
[
Permission.read(Role.any()),
Permission.update(Role.any()), // Permitimos a cualquiera actualizar por ahora para la demo
Permission.create(Role.any()),
Permission.delete(Role.any()),
]
);
console.log("Collection 'stream_state' created.");
// Attributes
await databases.createStringAttribute(dbId, 'stream_state', 'active_overlays', 10000, false);
console.log("Attributes created.");
} catch (e) {
if (e.code === 409) {
console.log("Collection already exists.");
} else {
console.error("Error creating collection:", e);
}
}
}
setupStreamState();

124
src/app/tv/overlay/page.tsx Normal file
View File

@ -0,0 +1,124 @@
'use client';
import { useState, useEffect } from 'react';
import { databases } from '@/lib/appwrite';
const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE || 'default';
const COLLECTION_ID = 'stream_state';
const DOCUMENT_ID = 'main_stream_state';
// El mismo modelo que en Android
type StreamOverlay = {
id: string;
type: 'LOWER_THIRD' | 'TICKER' | 'LOGO' | 'COMMENT';
text: string;
subtext: string;
isVisible: boolean;
position: string;
};
export default function CloudOverlayPage() {
const [overlays, setOverlays] = useState<StreamOverlay[]>([]);
useEffect(() => {
// Inicializar documento si no existe (opcional, lo crea Android)
databases.getDocument(DATABASE_ID, COLLECTION_ID, DOCUMENT_ID)
.then((doc) => {
try {
if (doc.active_overlays) {
setOverlays(JSON.parse(doc.active_overlays));
}
} catch(e) {}
})
.catch(() => console.log('Documento no encontrado aún'));
const unsubscribe = databases.client.subscribe(
`databases.${DATABASE_ID}.collections.${COLLECTION_ID}.documents.${DOCUMENT_ID}`,
(response: any) => {
if (response.payload && response.payload.active_overlays) {
try {
const parsed = JSON.parse(response.payload.active_overlays);
setOverlays(parsed);
} catch(e) {}
}
}
);
return () => unsubscribe();
}, []);
// Esta página debe tener fondo transparente
useEffect(() => {
document.body.style.backgroundColor = 'transparent';
}, []);
return (
<div className="w-screen h-screen overflow-hidden relative">
{overlays.filter(o => o.isVisible).map(overlay => {
return (
<OverlayRenderer key={overlay.id} overlay={overlay} />
);
})}
</div>
);
}
function OverlayRenderer({ overlay }: { overlay: StreamOverlay }) {
// Determinar posicionamiento
const getPositionClasses = () => {
switch (overlay.position) {
case 'BOTTOM_LEFT': return 'bottom-16 left-16';
case 'BOTTOM_CENTER': return 'bottom-16 left-1/2 -translate-x-1/2';
case 'BOTTOM_RIGHT': return 'bottom-16 right-16';
case 'TOP_LEFT': return 'top-16 left-16';
case 'TOP_CENTER': return 'top-16 left-1/2 -translate-x-1/2';
case 'TOP_RIGHT': return 'top-16 right-16';
default: return 'bottom-16 left-16';
}
};
const positionClass = getPositionClasses();
if (overlay.type === 'LOWER_THIRD') {
return (
<div className={`absolute ${positionClass} animate-slide-up flex items-center`}>
<div className="w-2 h-16 bg-red-600 shadow-[0_0_15px_rgba(220,38,38,0.8)]"></div>
<div className="bg-black/85 backdrop-blur-md border border-slate-800/50 p-4 pl-5 shadow-2xl flex flex-col justify-center min-w-[300px]">
<h1 className="text-white font-black text-3xl font-outfit uppercase tracking-tight">{overlay.text}</h1>
{overlay.subtext && <p className="text-slate-300 font-medium text-lg mt-1">{overlay.subtext}</p>}
</div>
</div>
);
}
if (overlay.type === 'COMMENT') {
return (
<div className={`absolute ${positionClass} animate-fade-in`}>
<div className="bg-slate-900/90 backdrop-blur-md border border-cyan-500/30 p-5 rounded-2xl shadow-[0_0_30px_rgba(6,182,212,0.3)] max-w-[500px]">
<p className="text-white text-2xl font-medium leading-relaxed">{overlay.text}</p>
</div>
</div>
);
}
if (overlay.type === 'TICKER') {
return (
<div className="absolute bottom-0 left-0 w-full bg-red-600/95 shadow-[0_-5px_20px_rgba(220,38,38,0.4)] overflow-hidden">
<div className="whitespace-nowrap py-3 px-4 flex items-center animate-[marquee_20s_linear_infinite]">
<span className="text-white font-bold text-xl uppercase tracking-wider mx-4">• {overlay.text}</span>
<span className="text-white font-bold text-xl uppercase tracking-wider mx-4">• {overlay.text}</span>
<span className="text-white font-bold text-xl uppercase tracking-wider mx-4">• {overlay.text}</span>
<span className="text-white font-bold text-xl uppercase tracking-wider mx-4">• {overlay.text}</span>
</div>
<style jsx>{`
@keyframes marquee {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
`}</style>
</div>
);
}
return null;
}

View File

@ -1,9 +1,10 @@
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { createAdminClient } from '@/lib/appwrite.server';
import ProgrammingSchedule from '@/components/ProgrammingSchedule';
import { Tv, RadioTower } from 'lucide-react';
import { Tv, RadioTower, MessageSquare } from 'lucide-react';
import { getEntityConfig } from '@/app/actions/entity';
import { LivePlayer } from '@/components/media/LivePlayer';
import LiveChatBox from '@/components/LiveChatBox'; // Nuevo componente cliente
export const revalidate = 60; // Revalida la TV cada minuto si hay cambios
@ -42,9 +43,24 @@ export default async function TVPage() {
</div>
</div>
{/* Video Player Container */}
{/* Video Player & Chat Container */}
<div className="container mx-auto px-4 -mt-10 relative z-10 mb-20">
<LivePlayer url={entity?.tv_url} type="tv" title={entity?.razon_social} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<LivePlayer url={entity?.tv_url} type="tv" title={entity?.razon_social} />
</div>
<div className="lg:col-span-1 h-[500px] lg:h-auto">
<div className="bg-[#1e2023] border border-slate-800 rounded-2xl flex flex-col h-full overflow-hidden shadow-[0_0_40px_rgba(0,0,0,0.5)]">
<div className="p-4 border-b border-slate-800 bg-slate-900/50 flex items-center gap-3">
<MessageSquare className="text-cyan-400" size={20} />
<h3 className="text-white font-bold font-outfit uppercase tracking-wider">Chat en Vivo</h3>
</div>
<div className="flex-1 relative">
<LiveChatBox />
</div>
</div>
</div>
</div>
</div>
{/* Parrilla */}

View File

@ -0,0 +1,112 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { account, databases } from '@/lib/appwrite';
import { Send, User } from 'lucide-react';
import { ID, Query } from 'appwrite';
const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE || 'default';
const COLLECTION_ID = 'live_chat';
export default function LiveChatBox() {
const [messages, setMessages] = useState<any[]>([]);
const [newMessage, setNewMessage] = useState('');
const [user, setUser] = useState<any>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Verificar sesión
account.get().then(setUser).catch(() => setUser(null));
// Cargar últimos 50
databases.listDocuments(DATABASE_ID, COLLECTION_ID, [
Query.orderDesc('timestamp'),
Query.limit(50)
]).then(res => {
setMessages(res.documents.reverse());
scrollToBottom();
}).catch(console.error);
// Suscribirse a nuevos mensajes
const unsubscribe = databases.client.subscribe(
`databases.${DATABASE_ID}.collections.${COLLECTION_ID}.documents`,
response => {
if (response.events.includes('databases.*.collections.*.documents.*.create')) {
setMessages(prev => [...prev, response.payload]);
setTimeout(scrollToBottom, 100);
}
}
);
return () => unsubscribe();
}, []);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const sendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !user) return;
try {
await databases.createDocument(DATABASE_ID, COLLECTION_ID, ID.unique(), {
user_id: user.$id,
user_name: user.name || 'Usuario',
message: newMessage.trim(),
timestamp: new Date().toISOString()
});
setNewMessage('');
} catch (error) {
console.error('Error sending message:', error);
alert('Error al enviar el mensaje');
}
};
return (
<div className="flex flex-col h-full bg-[#1e2023] rounded-xl overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-500 text-sm">
No hay mensajes aún. ¡Sé el primero!
</div>
) : (
messages.map((msg, idx) => (
<div key={msg.$id || idx} className="animate-fade-in">
<span className="font-bold text-cyan-400 text-sm mr-2">{msg.user_name}</span>
<span className="text-slate-300 text-sm">{msg.message}</span>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 bg-slate-900 border-t border-slate-800">
{!user ? (
<div className="text-center text-sm text-slate-400 bg-slate-800/50 p-3 rounded-lg border border-slate-700">
<User className="mx-auto mb-2 text-slate-500" size={20} />
<a href="/login" className="text-cyan-400 hover:underline font-bold">Inicia sesión</a> para participar en el chat
</div>
) : (
<form onSubmit={sendMessage} className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Escribe un mensaje..."
className="flex-1 bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none text-sm"
maxLength={500}
/>
<button
type="submit"
disabled={!newMessage.trim()}
className="bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-white p-2 rounded-lg transition-colors"
>
<Send size={18} />
</button>
</form>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { Client, Databases, Storage, Query } from 'appwrite';
import { Client, Databases, Storage, Query, Account } from 'appwrite';
const client = new Client();
@ -12,6 +12,7 @@ client
export const databases = new Databases(client);
export const storage = new Storage(client);
export const account = new Account(client);
export { Query };
export const APPWRITE_ALMEIRA_CONFIG = {