feat: implement live TV signal using dynamic entity configuration
This commit is contained in:
parent
4892a3d798
commit
1ea7ff359d
@ -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 */}
|
||||
|
||||
@ -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">
|
||||
|
||||
115
src/components/media/LivePlayer.tsx
Normal file
115
src/components/media/LivePlayer.tsx
Normal 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 ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user