feat: implement live TV signal using dynamic entity configuration

This commit is contained in:
Carlos (Almeira) 2026-03-29 22:41:10 -05:00
parent 4892a3d798
commit 1ea7ff359d
3 changed files with 132 additions and 17 deletions

View File

@ -1,12 +1,15 @@
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { createAdminClient } from '@/lib/appwrite.server';
import ProgrammingSchedule from '@/components/ProgrammingSchedule';
import { Tv, PlayCircle, RadioTower } from 'lucide-react';
import { Tv, RadioTower } from 'lucide-react';
import { getEntityConfig } from '@/app/actions/entity';
import { LivePlayer } from '@/components/media/LivePlayer';
export const revalidate = 60; // Revalida la TV cada minuto si hay cambios
export default async function TVPage() {
let matrixData = {};
let entity = null;
try {
const client = await createAdminClient();
@ -16,6 +19,7 @@ export default async function TVPage() {
'matrix_tv'
);
matrixData = JSON.parse(doc.matrix_data);
entity = await getEntityConfig();
} catch(e) {
console.error("No se pudo cargar parrilla de TV", e);
}
@ -40,14 +44,7 @@ export default async function TVPage() {
{/* Video Player Container */}
<div className="container mx-auto px-4 -mt-10 relative z-10 mb-20">
<div className="aspect-video bg-black rounded-3xl overflow-hidden border border-slate-800 shadow-2xl relative group">
{/* Placeholder para iframe de TV / HLS / YouTube */}
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-900">
<PlayCircle size={80} className="text-slate-700 mb-4 group-hover:scale-110 transition-transform group-hover:text-cyan-500 cursor-pointer" />
<span className="text-slate-500 font-black tracking-widest uppercase text-sm">Transmisión TV En Vivo</span>
<p className="text-slate-600 text-xs mt-2 max-w-md text-center">Aquí se incrustará el código provisto por tu proveedor de streaming de video (YouTube, Twitch, VBM, etc).</p>
</div>
</div>
<LivePlayer url={entity?.tv_url} type="tv" title={entity?.razon_social} />
</div>
{/* Parrilla */}

View File

@ -8,9 +8,10 @@ import { Query } from 'appwrite';
import { uploadMediaDocumentServer } from '@/app/actions/appwrite';
/** Utility to generate frontend Appwrite image previews */
export function getAppwriteImageUrl(fileId: string, width = 400, height = 250) {
export function getAppwriteImageUrl(fileId: string, bucketId?: string, width = 400, height = 250) {
if (!fileId) return '';
return `${endpoint}/storage/buckets/${APPWRITE_ALMEIRA_CONFIG.mediaBucketId}/files/${fileId}/preview?project=${projectId}&width=${width}&height=${height}&quality=80`;
const finalBucketId = bucketId || APPWRITE_ALMEIRA_CONFIG.mediaBucketId;
return `${endpoint}/storage/buckets/${finalBucketId}/files/${fileId}/preview?project=${projectId}&width=${width}&height=${height}&quality=80`;
}
interface MediaGridSelectorProps {
@ -101,7 +102,7 @@ export const MediaGridSelector = ({
const doc = res.data[0];
setMediaItems(prev => [doc, ...prev]);
const url = getAppwriteImageUrl(doc.file_id, 1200, 800);
const url = getAppwriteImageUrl(doc.file_id, doc.bucket_id, 1200, 800);
onChange(doc.$id, url);
closeModal();
} catch (error) {
@ -179,12 +180,13 @@ export const MediaGridSelector = ({
) : filteredItems.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredItems.map((item) => {
const url = getAppwriteImageUrl(item.file_id, 300, 300);
const url = getAppwriteImageUrl(item.file_id, item.bucket_id, 300, 300);
return (
<div
key={item.$id}
onClick={() => {
onChange(item.$id, getAppwriteImageUrl(item.file_id, 1200, 800));
const selectedUrl = getAppwriteImageUrl(item.file_id, item.bucket_id, 1200, 800);
onChange(item.$id, selectedUrl);
if (mode !== 'modal') setIsOpen(false);
closeModal();
}}
@ -261,11 +263,12 @@ export const MediaGridSelector = ({
// Si value existe, buscamos su documento para sacar el file_id
const selectedDoc = mediaItems.find(m => m.$id === value);
// Si aún no está en la lista (cargando), mostramos loader genérico
if (!selectedDoc && mediaItems.length > 0) return null; // No está en la DB o se paginó, idealmente pasamos un initialUrl.
const fileIdToRender = selectedDoc ? selectedDoc.file_id : value; // Fallback al propio ID por si es legacy.
if (!selectedDoc && mediaItems.length > 0) return null;
const fileIdToRender = selectedDoc ? selectedDoc.file_id : value;
const bucketIdToRender = selectedDoc ? selectedDoc.bucket_id : undefined;
return (
<img src={getAppwriteImageUrl(fileIdToRender, 800, 450)} alt="Selected Preview" className="w-full h-full object-cover" />
<img src={getAppwriteImageUrl(fileIdToRender, bucketIdToRender, 800, 450)} alt="Selected Preview" className="w-full h-full object-cover" />
);
})()}
<div className="absolute inset-0 bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">

View File

@ -0,0 +1,115 @@
'use client';
import { Tv, PlayCircle, RadioTower, WifiOff } from 'lucide-react';
import { useState, useEffect } from 'react';
interface LivePlayerProps {
url?: string;
type: 'tv' | 'radio';
title?: string;
}
export function LivePlayer({ url, type, title }: LivePlayerProps) {
const [processedUrl, setProcessedUrl] = useState<string | null>(null);
const [isYouTube, setIsYouTube] = useState(false);
useEffect(() => {
if (!url) {
setProcessedUrl(null);
return;
}
// YT Detection & Conversion
if (url.includes('youtube.com/watch?v=') || url.includes('youtu.be/')) {
const videoId = url.includes('watch?v=')
? url.split('v=')[1]?.split('&')[0]
: url.split('/').pop();
if (videoId) {
setProcessedUrl(`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`);
setIsYouTube(true);
}
} else {
setProcessedUrl(url);
setIsYouTube(false);
}
}, [url]);
if (!processedUrl) {
return (
<div className={`relative w-full rounded-3xl overflow-hidden border border-slate-800 shadow-2xl bg-slate-900 flex flex-col items-center justify-center p-12 transition-all ${type === 'tv' ? 'aspect-video' : 'h-64'}`}>
{/* Static Background Effect */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none bg-[url('https://media.giphy.com/media/oEI9uWUic6Rji/giphy.gif')] bg-cover mix-blend-overlay"></div>
<div className="relative z-10 flex flex-col items-center animate-in fade-in zoom-in duration-700">
<div className={`p-6 rounded-full mb-6 border-2 border-dashed animate-pulse ${type === 'tv' ? 'bg-cyan-500/10 border-cyan-500/30 text-cyan-400' : 'bg-rose-500/10 border-rose-500/30 text-rose-400'}`}>
<WifiOff size={64} />
</div>
<h2 className="text-4xl font-black font-outfit text-white tracking-tighter uppercase mb-2">SEÑAL PERDIDA</h2>
<p className="text-slate-500 font-bold uppercase tracking-widest text-[10px] md:text-xs text-center max-w-sm">
La transmisión de {type.toUpperCase()} no está disponible en este momento. Por favor, intenta más tarde.
</p>
</div>
{/* Decorative scanning line */}
<div className="absolute top-0 left-0 w-full h-1 bg-white/10 animate-[scan_3s_linear_infinite] pointer-events-none"></div>
</div>
);
}
return (
<div className={`relative w-full rounded-3xl overflow-hidden border border-slate-800 shadow-2xl bg-black transition-all ${type === 'tv' ? 'aspect-video' : 'h-48'}`}>
{type === 'tv' ? (
<iframe
src={processedUrl}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={title || "Live TV Stream"}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center relative p-8">
<div className="absolute inset-0 bg-gradient-to-r from-rose-600/10 to-orange-600/10 pointer-events-none" />
{/* Visualizer bars for Radio */}
<div className="absolute bottom-4 left-0 right-0 h-16 flex items-end justify-center gap-1 opacity-20 overflow-hidden px-8 pointer-events-none">
{Array.from({length: 40}).map((_, i) => (
<div key={i} className="w-2 bg-rose-500 rounded-t-sm animate-pulse" style={{ height: `${20 + Math.random() * 80}%`, animationDuration: `${0.4 + Math.random()}s` }}></div>
))}
</div>
<div className="relative z-10 flex flex-col items-center w-full max-w-lg">
<div className="flex items-center gap-4 mb-6">
<div className="p-3 bg-rose-500 rounded-full text-white shadow-xl shadow-rose-500/20">
<RadioTower size={24} />
</div>
<div>
<h3 className="text-xl font-black text-white uppercase tracking-tight leading-none mb-1">En Vivo: {title || 'Radio Online'}</h3>
<p className="text-rose-400 text-[10px] font-black uppercase tracking-widest">Sintonizando Almeira Radio HD</p>
</div>
</div>
{/* Standard Audio Player or Custom UI for Direct Stream */}
<audio
controls
autoPlay
className="w-full h-10 accent-rose-500"
src={processedUrl}
>
Tu navegador no soporta la reproducción de audio.
</audio>
<a
href={processedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[9px] font-bold text-slate-500 hover:text-white transition-colors mt-4 uppercase tracking-widest border border-slate-800 px-3 py-1 rounded-full"
>
Enlace Directo de Audio &nearr;
</a>
</div>
</div>
)}
</div>
);
}