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 { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
|
||||||
import { createAdminClient } from '@/lib/appwrite.server';
|
import { createAdminClient } from '@/lib/appwrite.server';
|
||||||
import ProgrammingSchedule from '@/components/ProgrammingSchedule';
|
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 const revalidate = 60; // Revalida la TV cada minuto si hay cambios
|
||||||
|
|
||||||
export default async function TVPage() {
|
export default async function TVPage() {
|
||||||
let matrixData = {};
|
let matrixData = {};
|
||||||
|
let entity = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await createAdminClient();
|
const client = await createAdminClient();
|
||||||
@ -16,6 +19,7 @@ export default async function TVPage() {
|
|||||||
'matrix_tv'
|
'matrix_tv'
|
||||||
);
|
);
|
||||||
matrixData = JSON.parse(doc.matrix_data);
|
matrixData = JSON.parse(doc.matrix_data);
|
||||||
|
entity = await getEntityConfig();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error("No se pudo cargar parrilla de TV", e);
|
console.error("No se pudo cargar parrilla de TV", e);
|
||||||
}
|
}
|
||||||
@ -40,14 +44,7 @@ export default async function TVPage() {
|
|||||||
|
|
||||||
{/* Video Player Container */}
|
{/* Video Player Container */}
|
||||||
<div className="container mx-auto px-4 -mt-10 relative z-10 mb-20">
|
<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">
|
<LivePlayer url={entity?.tv_url} type="tv" title={entity?.razon_social} />
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Parrilla */}
|
{/* Parrilla */}
|
||||||
|
|||||||
@ -8,9 +8,10 @@ import { Query } from 'appwrite';
|
|||||||
import { uploadMediaDocumentServer } from '@/app/actions/appwrite';
|
import { uploadMediaDocumentServer } from '@/app/actions/appwrite';
|
||||||
|
|
||||||
/** Utility to generate frontend Appwrite image previews */
|
/** 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 '';
|
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 {
|
interface MediaGridSelectorProps {
|
||||||
@ -101,7 +102,7 @@ export const MediaGridSelector = ({
|
|||||||
|
|
||||||
const doc = res.data[0];
|
const doc = res.data[0];
|
||||||
setMediaItems(prev => [doc, ...prev]);
|
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);
|
onChange(doc.$id, url);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -179,12 +180,13 @@ export const MediaGridSelector = ({
|
|||||||
) : filteredItems.length > 0 ? (
|
) : filteredItems.length > 0 ? (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{filteredItems.map((item) => {
|
{filteredItems.map((item) => {
|
||||||
const url = getAppwriteImageUrl(item.file_id, 300, 300);
|
const url = getAppwriteImageUrl(item.file_id, item.bucket_id, 300, 300);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.$id}
|
key={item.$id}
|
||||||
onClick={() => {
|
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);
|
if (mode !== 'modal') setIsOpen(false);
|
||||||
closeModal();
|
closeModal();
|
||||||
}}
|
}}
|
||||||
@ -261,11 +263,12 @@ export const MediaGridSelector = ({
|
|||||||
// Si value existe, buscamos su documento para sacar el file_id
|
// Si value existe, buscamos su documento para sacar el file_id
|
||||||
const selectedDoc = mediaItems.find(m => m.$id === value);
|
const selectedDoc = mediaItems.find(m => m.$id === value);
|
||||||
// Si aún no está en la lista (cargando), mostramos loader genérico
|
// 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.
|
if (!selectedDoc && mediaItems.length > 0) return null;
|
||||||
const fileIdToRender = selectedDoc ? selectedDoc.file_id : value; // Fallback al propio ID por si es legacy.
|
const fileIdToRender = selectedDoc ? selectedDoc.file_id : value;
|
||||||
|
const bucketIdToRender = selectedDoc ? selectedDoc.bucket_id : undefined;
|
||||||
|
|
||||||
return (
|
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">
|
<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