feat(streaming): Native Restreamer Dashboard integration
This commit is contained in:
parent
9680d8788f
commit
7e41c48dc1
1
almeira-live
Submodule
1
almeira-live
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 37dfe05a26b5c11da18b6e04480bf20719d8c42d
|
||||||
209
src/app/actions/restreamer.ts
Normal file
209
src/app/actions/restreamer.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Actions para interactuar con la API REST de Datarhei Restreamer.
|
||||||
|
* Restreamer expone una API Swagger en /api/swagger.
|
||||||
|
* Auth: POST /api/login → JWT token → Bearer en headers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RESTREAMER_BASE_URL = process.env.RESTREAMER_URL || 'https://stream.almeira.com.co';
|
||||||
|
const RESTREAMER_USER = process.env.RESTREAMER_USER || 'admin';
|
||||||
|
const RESTREAMER_PASS = process.env.RESTREAMER_PASS || '';
|
||||||
|
|
||||||
|
// ── Auth Token (se renueva en cada llamada del servidor) ──────────────────────
|
||||||
|
async function getRestreamToken(): Promise<string> {
|
||||||
|
const res = await fetch(`${RESTREAMER_BASE_URL}/api/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: RESTREAMER_USER, password: RESTREAMER_PASS }),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Restreamer auth failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restreamFetch(path: string, opts: RequestInit = {}) {
|
||||||
|
const token = await getRestreamToken();
|
||||||
|
const res = await fetch(`${RESTREAMER_BASE_URL}${path}`, {
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(opts.headers || {}),
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Restreamer API error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tipos ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RestreamOutput = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string; // RTMP URL completa con stream key
|
||||||
|
state: 'running' | 'stopped' | 'failed' | 'idle';
|
||||||
|
platform: 'youtube' | 'facebook' | 'twitch' | 'tiktok' | 'custom';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RestreamServerInfo = {
|
||||||
|
version: string;
|
||||||
|
uptime_seconds: number;
|
||||||
|
is_connected: boolean;
|
||||||
|
active_ingest: boolean;
|
||||||
|
viewer_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Información del servidor ───────────────────────────────────────────────────
|
||||||
|
export async function getRestreamServerInfo(): Promise<{ success: boolean; data?: RestreamServerInfo; error?: string }> {
|
||||||
|
try {
|
||||||
|
const data = await restreamFetch('/api/v3/metrics/process/restreamer-ui:ingest:*');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
version: data?.version ?? 'desconocida',
|
||||||
|
uptime_seconds: data?.uptime ?? 0,
|
||||||
|
is_connected: true,
|
||||||
|
active_ingest: data?.state === 'running',
|
||||||
|
viewer_count: data?.viewers ?? 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
// Si falla el endpoint de metrics, devolvemos status básico
|
||||||
|
try {
|
||||||
|
await restreamFetch('/api/v3/config/active');
|
||||||
|
return { success: true, data: { version: '-', uptime_seconds: 0, is_connected: true, active_ingest: false, viewer_count: 0 } };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Listar outputs (procesos de egress) ───────────────────────────────────────
|
||||||
|
export async function listRestreamOutputs(): Promise<{ success: boolean; data: RestreamOutput[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
// Los outputs en Restreamer son procesos con ID que contienen 'egress'
|
||||||
|
const processes: any[] = await restreamFetch('/api/v3/process');
|
||||||
|
const outputs: RestreamOutput[] = processes
|
||||||
|
.filter((p: any) => p.id?.includes('egress') || p.reference?.includes('egress'))
|
||||||
|
.map((p: any) => {
|
||||||
|
const addr: string = p.config?.output?.[0]?.address ?? '';
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: p.metadata?.['restreamer-ui']?.name ?? p.id,
|
||||||
|
address: addr,
|
||||||
|
state: p.state?.exec ?? 'idle',
|
||||||
|
platform: detectPlatform(addr),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { success: true, data: outputs };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, data: [], error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crear output ──────────────────────────────────────────────────────────────
|
||||||
|
export async function createRestreamOutput(params: {
|
||||||
|
name: string;
|
||||||
|
rtmpUrl: string;
|
||||||
|
streamKey: string;
|
||||||
|
platform: string;
|
||||||
|
}): Promise<{ success: boolean; id?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const address = `${params.rtmpUrl.replace(/\/$/, '')}/${params.streamKey}`;
|
||||||
|
const id = `restreamer-ui:egress:rtmp:${Date.now()}`;
|
||||||
|
|
||||||
|
await restreamFetch('/api/v3/process', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
reference: 'restreamer-ui:ingest:*',
|
||||||
|
config: {
|
||||||
|
id,
|
||||||
|
reconnect: true,
|
||||||
|
reconnect_delay_seconds: 15,
|
||||||
|
autostart: false,
|
||||||
|
input: [{ id: 'input_0', address: '{memfs}/{reference}.m3u8', options: ['-re'] }],
|
||||||
|
output: [{ id: 'output_0', address, options: ['-codec', 'copy', '-f', 'flv'] }],
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
'restreamer-ui': {
|
||||||
|
name: params.name,
|
||||||
|
platform: params.platform,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return { success: true, id };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Iniciar output ────────────────────────────────────────────────────────────
|
||||||
|
export async function startRestreamOutput(id: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await restreamFetch(`/api/v3/process/${encodeURIComponent(id)}/command`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ command: 'start' }),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detener output ────────────────────────────────────────────────────────────
|
||||||
|
export async function stopRestreamOutput(id: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await restreamFetch(`/api/v3/process/${encodeURIComponent(id)}/command`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ command: 'stop' }),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eliminar output ───────────────────────────────────────────────────────────
|
||||||
|
export async function deleteRestreamOutput(id: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await restreamFetch(`/api/v3/process/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Estadísticas del ingest principal ────────────────────────────────────────
|
||||||
|
export async function getIngestStats(): Promise<{ success: boolean; data?: { bitrate: number; fps: number; state: string }; error?: string }> {
|
||||||
|
try {
|
||||||
|
const processes: any[] = await restreamFetch('/api/v3/process');
|
||||||
|
const ingest = processes.find((p: any) => p.id?.includes('ingest'));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
state: ingest?.state?.exec ?? 'stopped',
|
||||||
|
bitrate: ingest?.state?.progress?.speed ?? 0,
|
||||||
|
fps: ingest?.state?.progress?.fps ?? 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: detecta plataforma por URL ───────────────────────────────────────
|
||||||
|
function detectPlatform(url: string): RestreamOutput['platform'] {
|
||||||
|
if (url.includes('youtube.com') || url.includes('a.rtmp.youtube')) return 'youtube';
|
||||||
|
if (url.includes('facebook.com') || url.includes('live-api')) return 'facebook';
|
||||||
|
if (url.includes('twitch.tv') || url.includes('live.twitch')) return 'twitch';
|
||||||
|
if (url.includes('tiktok')) return 'tiktok';
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
304
src/app/dashboard/streaming/StreamingDashboard.tsx
Normal file
304
src/app/dashboard/streaming/StreamingDashboard.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Activity, RadioTower, Users, Clock,
|
||||||
|
Play, Square, Trash2, Plus,
|
||||||
|
Youtube, Facebook, Twitch, Globe, RefreshCw
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
getRestreamServerInfo, listRestreamOutputs,
|
||||||
|
createRestreamOutput, startRestreamOutput, stopRestreamOutput, deleteRestreamOutput,
|
||||||
|
RestreamServerInfo, RestreamOutput
|
||||||
|
} from '@/app/actions/restreamer';
|
||||||
|
|
||||||
|
export default function StreamingDashboard() {
|
||||||
|
const [info, setInfo] = useState<RestreamServerInfo | null>(null);
|
||||||
|
const [outputs, setOutputs] = useState<RestreamOutput[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [newOutput, setNewOutput] = useState({ name: '', platform: 'youtube', rtmpUrl: '', streamKey: '' });
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const [infoRes, outRes] = await Promise.all([
|
||||||
|
getRestreamServerInfo(),
|
||||||
|
listRestreamOutputs()
|
||||||
|
]);
|
||||||
|
if (infoRes.success && infoRes.data) setInfo(infoRes.data);
|
||||||
|
if (outRes.success) setOutputs(outRes.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching restreamer data", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
const interval = setInterval(fetchData, 10000); // Poll cada 10s
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateOutput = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const res = await createRestreamOutput(newOutput);
|
||||||
|
if (res.success) {
|
||||||
|
setIsAdding(false);
|
||||||
|
setNewOutput({ name: '', platform: 'youtube', rtmpUrl: '', streamKey: '' });
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
alert("Error al crear la retransmisión: " + res.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (action: 'start' | 'stop' | 'delete', id: string) => {
|
||||||
|
let res;
|
||||||
|
if (action === 'start') res = await startRestreamOutput(id);
|
||||||
|
else if (action === 'stop') res = await stopRestreamOutput(id);
|
||||||
|
else if (action === 'delete') {
|
||||||
|
if (confirm('¿Estás seguro de eliminar este destino?')) {
|
||||||
|
res = await deleteRestreamOutput(id);
|
||||||
|
} else return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res && res.success) {
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
alert(`Error al ejecutar acción: ${res?.error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUptime = (seconds: number) => {
|
||||||
|
if (!seconds) return '0s';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlatformIcon = ({ platform }: { platform: string }) => {
|
||||||
|
switch(platform) {
|
||||||
|
case 'youtube': return <Youtube size={18} className="text-red-500" />;
|
||||||
|
case 'facebook': return <Facebook size={18} className="text-blue-500" />;
|
||||||
|
case 'twitch': return <Twitch size={18} className="text-purple-500" />;
|
||||||
|
default: return <Globe size={18} className="text-cyan-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="text-white text-center py-20 flex flex-col items-center gap-4"><RefreshCw className="animate-spin text-cyan-400" size={32} /> Cargando datos del servidor...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-5 shadow-lg flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-lg ${info?.is_connected ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500'}`}>
|
||||||
|
<Activity size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 text-sm font-medium uppercase tracking-wider">Estado API</p>
|
||||||
|
<p className="text-white font-bold text-xl">{info?.is_connected ? 'Conectado' : 'Desconectado'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-5 shadow-lg flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-lg ${info?.active_ingest ? 'bg-cyan-500/10 text-cyan-400 animate-pulse' : 'bg-slate-700/50 text-slate-500'}`}>
|
||||||
|
<RadioTower size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 text-sm font-medium uppercase tracking-wider">Señal Principal</p>
|
||||||
|
<p className="text-white font-bold text-xl">{info?.active_ingest ? 'EN VIVO' : 'Apagada'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-5 shadow-lg flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-blue-500/10 text-blue-400 rounded-lg">
|
||||||
|
<Users size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 text-sm font-medium uppercase tracking-wider">Espectadores</p>
|
||||||
|
<p className="text-white font-bold text-xl">{info?.viewer_count || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-5 shadow-lg flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-purple-500/10 text-purple-400 rounded-lg">
|
||||||
|
<Clock size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-slate-400 text-sm font-medium uppercase tracking-wider">Uptime</p>
|
||||||
|
<p className="text-white font-bold text-xl">{formatUptime(info?.uptime_seconds || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ingest Info & Keys */}
|
||||||
|
<div className="bg-slate-900 border border-cyan-500/30 rounded-xl p-6 shadow-[0_0_20px_rgba(6,182,212,0.1)]">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2"><RadioTower size={20} className="text-cyan-400" /> Parámetros de Transmisión (Para App Móvil y OBS)</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400 block mb-1">Servidor RTMP</label>
|
||||||
|
<div className="bg-black/50 border border-slate-700 rounded-lg p-3 text-cyan-300 font-mono text-sm break-all">
|
||||||
|
rtmp://streaming.almeira.com.co/live
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400 block mb-1">Stream Key (Recomendada)</label>
|
||||||
|
<div className="bg-black/50 border border-slate-700 rounded-lg p-3 text-white font-mono text-sm">
|
||||||
|
main_stream <span className="text-slate-500 text-xs ml-2">(o cualquier clave única)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500 text-sm mt-4">
|
||||||
|
Utiliza estos parámetros en tu aplicación Almeira Live o en OBS Studio para enviar video al servidor Restreamer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Restream Outputs List */}
|
||||||
|
<div className="bg-[#1e2023] border border-slate-800 rounded-xl p-6 shadow-lg">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-xl font-black font-outfit uppercase tracking-tighter text-white">Retransmisiones (Multistream)</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => fetchData()} className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors border border-slate-700">
|
||||||
|
<RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setIsAdding(!isAdding)} className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white rounded-lg text-sm font-bold uppercase transition-colors">
|
||||||
|
<Plus size={16} /> Agregar Destino
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdding && (
|
||||||
|
<form onSubmit={handleCreateOutput} className="bg-slate-900 border border-slate-700 rounded-lg p-5 mb-6 animate-fade-in">
|
||||||
|
<h4 className="text-white font-bold mb-4">Nuevo Destino de Retransmisión</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 uppercase tracking-wider block mb-1">Plataforma</label>
|
||||||
|
<select
|
||||||
|
value={newOutput.platform}
|
||||||
|
onChange={(e) => setNewOutput({...newOutput, platform: e.target.value})}
|
||||||
|
className="w-full bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="youtube">YouTube</option>
|
||||||
|
<option value="facebook">Facebook</option>
|
||||||
|
<option value="twitch">Twitch</option>
|
||||||
|
<option value="custom">Custom RTMP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 uppercase tracking-wider block mb-1">Nombre Descriptivo</label>
|
||||||
|
<input
|
||||||
|
type="text" required
|
||||||
|
placeholder="Ej: Canal Secundario YouTube"
|
||||||
|
value={newOutput.name}
|
||||||
|
onChange={(e) => setNewOutput({...newOutput, name: e.target.value})}
|
||||||
|
className="w-full bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 uppercase tracking-wider block mb-1">URL RTMP del destino</label>
|
||||||
|
<input
|
||||||
|
type="text" required
|
||||||
|
placeholder="rtmp://a.rtmp.youtube.com/live2"
|
||||||
|
value={newOutput.rtmpUrl}
|
||||||
|
onChange={(e) => setNewOutput({...newOutput, rtmpUrl: e.target.value})}
|
||||||
|
className="w-full bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 uppercase tracking-wider block mb-1">Stream Key</label>
|
||||||
|
<input
|
||||||
|
type="password" required
|
||||||
|
placeholder="****-****-****-****"
|
||||||
|
value={newOutput.streamKey}
|
||||||
|
onChange={(e) => setNewOutput({...newOutput, streamKey: e.target.value})}
|
||||||
|
className="w-full bg-black border border-slate-700 rounded-lg px-4 py-2 text-white focus:border-cyan-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setIsAdding(false)} className="px-4 py-2 bg-slate-800 text-slate-300 rounded-lg hover:bg-slate-700 text-sm font-medium">Cancelar</button>
|
||||||
|
<button type="submit" className="px-4 py-2 bg-cyan-600 text-white rounded-lg hover:bg-cyan-500 text-sm font-bold uppercase">Guardar y Añadir</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outputs.length === 0 ? (
|
||||||
|
<div className="text-center py-10 bg-black/20 rounded-lg border border-dashed border-slate-700">
|
||||||
|
<Globe size={32} className="mx-auto text-slate-600 mb-3" />
|
||||||
|
<p className="text-slate-400">No hay destinos configurados.</p>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Añade plataformas como YouTube o Facebook para retransmitir tu señal.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 text-slate-500 text-xs uppercase tracking-wider">
|
||||||
|
<th className="pb-3 font-medium">Destino</th>
|
||||||
|
<th className="pb-3 font-medium">URL</th>
|
||||||
|
<th className="pb-3 font-medium">Estado</th>
|
||||||
|
<th className="pb-3 font-medium text-right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
|
{outputs.map((out) => (
|
||||||
|
<tr key={out.id} className="hover:bg-slate-800/30 transition-colors">
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-black rounded-lg border border-slate-800">
|
||||||
|
<PlatformIcon platform={out.platform} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">{out.name}</p>
|
||||||
|
<p className="text-slate-500 text-xs capitalize">{out.platform}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<p className="text-slate-400 font-mono text-xs truncate max-w-[200px]" title={out.address}>{out.address.replace(/\/[^\/]+$/, '/****')}</p>
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
{out.state === 'running' ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span> Transmitiendo
|
||||||
|
</span>
|
||||||
|
) : out.state === 'failed' ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span> Error
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-500/10 text-slate-400 border border-slate-500/20">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-slate-500"></span> Detenido
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{out.state !== 'running' ? (
|
||||||
|
<button onClick={() => handleAction('start', out.id)} className="p-2 bg-green-500/10 hover:bg-green-500/20 text-green-400 rounded-lg transition-colors" title="Iniciar Transmisión">
|
||||||
|
<Play size={16} className="fill-current" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => handleAction('stop', out.id)} className="p-2 bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 rounded-lg transition-colors" title="Detener Transmisión">
|
||||||
|
<Square size={16} className="fill-current" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => handleAction('delete', out.id)} className="p-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg transition-colors" title="Eliminar Destino">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import { RadioTower, ExternalLink } from 'lucide-react';
|
import { RadioTower, Settings } from 'lucide-react';
|
||||||
|
import StreamingDashboard from './StreamingDashboard';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Gestor StreamingTV | Almeira CMS',
|
title: 'Gestor StreamingTV | Almeira CMS',
|
||||||
@ -16,30 +18,31 @@ export default function StreamingAdminPage() {
|
|||||||
Gestor StreamingTV
|
Gestor StreamingTV
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-slate-400 mt-1 max-w-2xl">
|
<p className="text-slate-400 mt-1 max-w-2xl">
|
||||||
Panel administrativo integrado del servidor Datarhei Restreamer. Desde aquí puedes controlar la captura, el estado y las transmisiones de tu canal de TV.
|
Panel administrativo integrado del servidor Datarhei Restreamer. Controla la captura, el estado y las retransmisiones (multistream) de tu canal de TV.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-bold uppercase tracking-wider transition-colors border border-slate-700"
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
Config Global
|
||||||
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="https://stream.almeira.com.co/ui/"
|
href="https://stream.almeira.com.co/ui/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-cyan-400 rounded-lg text-sm font-bold uppercase tracking-wider transition-colors border border-slate-700 hover:border-cyan-500/50"
|
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-cyan-400 rounded-lg text-sm font-bold uppercase tracking-wider transition-colors border border-slate-700 hover:border-cyan-500/50"
|
||||||
>
|
>
|
||||||
<ExternalLink size={16} />
|
<RadioTower size={16} />
|
||||||
Abrir en Pestaña Nueva
|
Restreamer UI
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full bg-[#1e2023] rounded-2xl overflow-hidden shadow-[0_0_40px_rgba(0,0,0,0.5)] border border-slate-800 relative z-10" style={{ height: 'calc(100vh - 200px)', minHeight: '600px' }}>
|
|
||||||
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-black/50 to-transparent pointer-events-none z-20"></div>
|
|
||||||
|
|
||||||
<iframe
|
|
||||||
src="https://stream.almeira.com.co/ui/"
|
|
||||||
className="w-full h-full border-none"
|
|
||||||
title="Datarhei Restreamer Panel"
|
|
||||||
allow="camera; microphone; fullscreen; display-capture"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Nuevo Dashboard Nativo */}
|
||||||
|
<StreamingDashboard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user