666 lines
22 KiB
TypeScript

import React, { useState, useEffect } from "react";
import {
MdMoreVert,
MdVideocam,
MdPersonAdd,
MdEdit,
MdOpenInNew,
MdDelete,
} from "react-icons/md";
import { Dropdown } from "./Dropdown";
import { FaYoutube, FaFacebook, FaTwitch, FaLinkedin } from "react-icons/fa";
import { SkeletonTable } from "./Skeleton";
import styles from "./TransmissionsTable.module.css";
import InviteGuestsModal from "./InviteGuestsModal";
import { NewTransmissionModal } from "@shared/components";
import type { Transmission } from "@shared/types";
import useStudioLauncher from "../hooks/useStudioLauncher";
import useStudioMessageListener from "../hooks/useStudioMessageListener";
import StudioPortal from "../features/studio/StudioPortal";
interface Props {
transmissions: Transmission[];
onDelete: (id: string) => void;
onUpdate: (t: Transmission) => void;
isLoading?: boolean;
}
const platformIcons: Record<string, React.ReactNode> = {
YouTube: <FaYoutube size={16} color="#FF0000" />,
Facebook: <FaFacebook size={16} color="#1877F2" />,
Twitch: <FaTwitch size={16} color="#9146FF" />,
LinkedIn: <FaLinkedin size={16} color="#0A66C2" />,
Generico: <MdVideocam size={16} color="#5f6368" />, // Logo genérico para transmisiones sin destino
};
const TransmissionsTable: React.FC<Props> = (props) => {
const { transmissions, onDelete, onUpdate, isLoading } = props;
const [activeTab, setActiveTab] = useState<"upcoming" | "past">("upcoming");
const [inviteOpen, setInviteOpen] = useState(false);
const [inviteLink, setInviteLink] = useState<string | undefined>(undefined);
const [editOpen, setEditOpen] = useState(false);
const [editTransmission, setEditTransmission] = useState<
Transmission | undefined
>(undefined);
const {
openStudio,
loadingId: launcherLoadingId,
error: launcherError,
} = useStudioLauncher();
const [loadingId, setLoadingId] = useState<string | null>(null);
const [studioSession, setStudioSession] = useState<{
serverUrl?: string;
token?: string;
room?: string;
} | null>(null);
const [validating, setValidating] = useState<boolean>(false);
const [connectError, setConnectError] = useState<string | null>(null);
const [currentAttempt, setCurrentAttempt] = useState<Transmission | null>(
null,
);
// Listen for external postMessage events carrying a LIVEKIT_TOKEN payload.
useStudioMessageListener((msg) => {
try {
if (msg && msg.token) {
const serverUrl =
msg.url || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || "";
// start validating token and open StudioPortal overlay
setValidating(true);
setConnectError(null);
setStudioSession({
serverUrl,
token: msg.token,
room: msg.room || "external",
});
}
} catch (e) {
/* ignore */
}
});
// Auto-open studio if token is present in URL (INCLUDE_TOKEN_IN_REDIRECT flow)
useEffect(() => {
try {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const tokenParam = params.get("token");
if (tokenParam) {
const serverParam =
params.get("serverUrl") ||
params.get("url") ||
(import.meta.env.VITE_LIVEKIT_WS_URL as string) ||
"";
const roomParam = params.get("room") || "external";
setConnectError(null);
setValidating(true);
setStudioSession({
serverUrl: serverParam,
token: tokenParam,
room: roomParam,
});
}
} catch (e) {
/* ignore */
}
}, []);
const handleEdit = (t: Transmission) => {
setEditTransmission(t);
setEditOpen(true);
};
// Filtrado por fechas
const filtered = transmissions.filter((t: Transmission) => {
// Si es "Próximamente" o no tiene fecha programada, siempre va a "upcoming"
if (!t.scheduled || t.scheduled === "Próximamente")
return activeTab === "upcoming";
const scheduledDate = new Date(t.scheduled);
const now = new Date();
if (activeTab === "upcoming") {
return scheduledDate >= now;
} else {
return scheduledDate < now;
}
});
const openStudioForTransmission = async (t: Transmission) => {
if (loadingId || launcherLoadingId) return;
setLoadingId(t.id);
setCurrentAttempt(t);
setValidating(true);
try {
const userRaw = localStorage.getItem("avanzacast_user") || "Demo User";
const user = userRaw;
const room = t.id || "avanzacast-studio";
const result = await openStudio({ room, username: user });
if (!result) {
// don't throw here — surface error in UI instead and stop loading
setConnectError(
"No se pudo abrir el estudio (launcher devolvió resultado vacío)",
);
setValidating(false);
setLoadingId(null);
return;
}
const resAny: any = result as any;
// If the token-server returns a direct studioUrl (redirect) but no token/id, navigate there
if (resAny && resAny.studioUrl && !resAny.token && !resAny.id) {
try {
const target = resAny.studioUrl;
try {
window.location.href = target;
return;
} catch (e) {
try {
window.location.assign(target);
return;
} catch (e2) {
/* ignore */
}
}
} catch (e) {
/* ignore navigation errors */
}
}
// If backend returned a session id, persist it and navigate to broadcastPanel/:id so the Studio route picks it
if (resAny && resAny.id) {
try {
const storeKey =
(import.meta.env.VITE_STUDIO_SESSION_KEY as string) ||
"avanzacast_studio_session";
sessionStorage.setItem(storeKey, JSON.stringify(resAny));
try {
window.dispatchEvent(
new CustomEvent("AVZ_STUDIO_SESSION", { detail: resAny }),
);
} catch (e) {
/* ignore */
}
} catch (e) {
/* ignore storage errors */
}
// Prefer explicit VITE_BROADCASTPANEL_URL; if not provided and we're running on localhost,
// default to the public host so the redirect goes to the deployed Broadcast Panel.
const envBroadcast =
(import.meta.env.VITE_BROADCASTPANEL_URL as string) || "";
let BROADCAST_BASE =
envBroadcast ||
(typeof window !== "undefined" ? window.location.origin : "");
if (
!envBroadcast &&
typeof window !== "undefined" &&
window.location.hostname === "localhost"
) {
BROADCAST_BASE =
"https://avanzacast-broadcastpanel.bfzqqk.easypanel.host";
}
// Usar ruta amigable /studio/:id para redirigir al Studio Portal que validará el token/room
const target = `${BROADCAST_BASE.replace(/\/$/, "")}/studio/${encodeURIComponent(resAny.id)}`;
try {
window.location.href = target;
return;
} catch (e) {
try {
window.location.assign(target);
} catch (e2) {
/* ignore */
}
}
}
// If app is configured as integrated, ensure we open StudioPortal overlay immediately
const INTEGRATED =
import.meta.env.VITE_STUDIO_INTEGRATED === "true" ||
import.meta.env.VITE_STUDIO_INTEGRATED === "1" ||
false;
if (INTEGRATED && resAny && resAny.token) {
const serverUrl =
resAny.url ||
resAny.studioUrl ||
(import.meta.env.VITE_LIVEKIT_WS_URL as string) ||
"";
setStudioSession({
serverUrl,
token: resAny.token,
room: resAny.room || room,
});
setLoadingId(null);
return;
}
const serverUrl =
resAny.url ||
resAny.studioUrl ||
(import.meta.env.VITE_LIVEKIT_WS_URL as string) ||
"";
if (resAny.token) {
setStudioSession({
serverUrl,
token: resAny.token,
room: resAny.room || room,
});
} else {
setValidating(false);
}
setLoadingId(null);
} catch (err: any) {
console.error("[BroadcastPanel] Error entrando al estudio:", err);
setConnectError(
err?.message ||
"No fue posible entrar al estudio. Revisa el servidor de tokens.",
);
setValidating(false);
setLoadingId(null);
}
};
const closeStudio = () => {
try {
setStudioSession(null);
} catch (e) {}
};
if (isLoading) {
return (
<div className={styles.transmissionsSection}>
<div className={styles.tabContainer}>
<button className={`${styles.tabButton} ${styles.activeTab}`}>
Próximamente
</button>
<button className={styles.tabButton}>Anteriores</button>
</div>
<SkeletonTable rows={5} />
</div>
);
}
return (
<div className={styles.transmissionsSection}>
<div className={styles.tabContainer}>
<button
onClick={() => setActiveTab("upcoming")}
className={`${styles.tabButton} ${activeTab === "upcoming" ? styles.activeTab : ""}`}
>
Próximamente
</button>
<button
onClick={() => setActiveTab("past")}
className={`${styles.tabButton} ${activeTab === "past" ? styles.activeTab : ""}`}
>
Anteriores
</button>
</div>
{!filtered || filtered.length === 0 ? (
<div className={styles.tableWrapper}>
<div className={styles.noDataCell}>
{activeTab === "upcoming"
? "No hay transmisiones programadas todavía."
: "No hay transmisiones anteriores."}
</div>
</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.transmissionsTable}>
<thead>
<tr>
<th className={styles.tableHeader}>Título</th>
<th className={styles.tableHeader}>Creado</th>
<th className={styles.tableHeader}>Programado</th>
<th
className={styles.tableHeader}
style={{ textAlign: "right" }}
></th>
</tr>
</thead>
<tbody>
{filtered.map((t: Transmission) => (
<tr key={t.id} className={styles.tableRow}>
<td className={styles.tableCell}>
<div
style={{ display: "flex", alignItems: "center", gap: 12 }}
>
<div className={styles.platformAvatar}>
<div className={styles.platformIcon}>
{platformIcons[t.platform] ||
platformIcons["YouTube"]}
</div>
</div>
<div>
<div className={styles.transmissionTitle}>
{t.title}
</div>
<div
style={{
fontSize: 12,
color: "var(--text-secondary)",
}}
>
{t.platform === "Generico"
? "Solo grabación"
: t.platform || "YouTube"}
</div>
</div>
</div>
</td>
<td className={styles.tableCell}>
<span
style={{ fontSize: 14, color: "var(--text-primary)" }}
>
{t.createdAt || "---"}
</span>
</td>
<td className={styles.tableCell}>
<span
style={{ fontSize: 14, color: "var(--text-primary)" }}
>
{t.scheduled && t.scheduled !== "Próximamente"
? t.scheduled
: "---"}
</span>
</td>
<td
className={styles.tableCell}
style={{ textAlign: "right" }}
>
<div className={styles.actionsCell}>
<button
aria-label={`Entrar al estudio ${t.title}`}
className={styles.enterStudioButton}
disabled={
loadingId !== null || launcherLoadingId !== null
}
onClick={() => openStudioForTransmission(t)}
>
{loadingId === t.id || launcherLoadingId === t.id ? (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
>
<svg
width="16"
height="16"
viewBox="0 0 50 50"
style={{ animation: "spin 1s linear infinite" }}
>
<circle
cx="25"
cy="25"
r="20"
fill="none"
stroke="#fff"
strokeWidth="5"
strokeLinecap="round"
strokeDasharray="31.4 31.4"
/>
</svg>
Entrando...
</span>
) : (
"Entrar al estudio"
)}
</button>
{launcherError && (
// Mostrar modal claro si el hook de launcher reporta un error
<div
style={{
position: "fixed",
inset: 0,
zIndex: 12500,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
background: "#fff",
color: "#111827",
padding: 20,
borderRadius: 8,
maxWidth: 600,
}}
>
<h3>Error al iniciar el estudio</h3>
<p>{launcherError}</p>
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
}}
>
<button
onClick={() => {
/* cerrar el error del launcher */ window.location.reload();
}}
className="btn"
>
Cerrar
</button>
</div>
</div>
</div>
)}
<Dropdown
trigger={
<button
className={styles.moreOptionsButton}
aria-label={`Más opciones ${t.title}`}
>
<MdMoreVert size={20} />
</button>
}
items={[
{
label: "Agregar invitados",
icon: <MdPersonAdd size={16} />,
onClick: () => {
setInviteLink(`https://streamyard.com/${t.id}`);
setInviteOpen(true);
},
},
{
label: "Editar",
icon: <MdEdit size={16} />,
onClick: () => handleEdit(t),
},
{ divider: true, label: "", disabled: false },
{
label: "Ver en YouTube",
icon: <MdOpenInNew size={16} />,
onClick: () => {
/* abrir */
},
},
{ divider: true, label: "", disabled: false },
{
label: "Eliminar transmisión",
icon: <MdDelete size={16} />,
onClick: () => onDelete(t.id),
containerProps: { className: styles.deleteItem },
labelProps: { className: styles.dangerLabel },
},
]}
/>
<InviteGuestsModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
link={inviteLink || ""}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<NewTransmissionModal
open={editOpen}
onClose={() => {
setEditOpen(false);
setEditTransmission(undefined);
}}
onCreate={() => {}}
onUpdate={onUpdate}
transmission={editTransmission}
/>
{studioSession && (
<div
className={styles.studioOverlay}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: "95%",
maxWidth: 1200,
height: "90%",
background: "var(--studio-bg-primary)",
borderRadius: 8,
overflow: "hidden",
position: "relative",
}}
>
<button
onClick={() => {
setValidating(false);
closeStudio();
}}
style={{
position: "absolute",
right: 12,
top: 12,
zIndex: 10100,
padding: "8px 12px",
borderRadius: 6,
}}
>
Cerrar
</button>
<StudioPortal
serverUrl={studioSession.serverUrl || ""}
token={studioSession.token || ""}
roomName={studioSession.room || ""}
onRoomConnected={() => {
setValidating(false); /* keep portal open */
}}
onRoomDisconnected={() => {
closeStudio();
}}
onRoomConnectError={(err) => {
setValidating(false);
setConnectError(
String(err?.message || err || "Error al conectar"),
);
}}
/>
</div>
</div>
)}
{validating && (
<div
className={styles.validationOverlay}
style={{
position: "fixed",
inset: 0,
zIndex: 11000,
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
}}
>
<div
style={{
background: "rgba(0,0,0,0.6)",
color: "#fff",
padding: 16,
borderRadius: 8,
pointerEvents: "auto",
}}
>
Validando token, por favor espera...
</div>
</div>
)}
{connectError && (
<div
className={styles.errorModal}
style={{
position: "fixed",
inset: 0,
zIndex: 12000,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
background: "#fff",
color: "#111827",
padding: 20,
borderRadius: 8,
maxWidth: 600,
}}
>
<h3>Error al conectar al estudio</h3>
<p>{connectError}</p>
<div
style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}
>
<button
onClick={() => {
setConnectError(null);
setStudioSession(null);
setCurrentAttempt(null);
}}
className="btn"
>
Cerrar
</button>
<button
onClick={() => {
if (currentAttempt) {
setConnectError(null);
openStudioForTransmission(currentAttempt);
}
}}
className="btn btn-primary"
>
Reintentar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default TransmissionsTable;