666 lines
22 KiB
TypeScript
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;
|