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:
parent
155546f90e
commit
699cfa232d
16
package-lock.json
generated
16
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
44
setup_live_chat.js
Normal 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
41
setup_stream_state.js
Normal 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
124
src/app/tv/overlay/page.tsx
Normal 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;
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
112
src/components/LiveChatBox.tsx
Normal file
112
src/components/LiveChatBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user