228 lines
7.0 KiB
TypeScript
228 lines
7.0 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import Script from 'next/script';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
DEFAULT_JITSI_CONFIG_OVERRIDES,
|
|
DEFAULT_JITSI_INTERFACE_OVERRIDES,
|
|
} from '@/lib/jitsi';
|
|
|
|
declare global {
|
|
interface Window {
|
|
JitsiMeetExternalAPI: any;
|
|
}
|
|
}
|
|
|
|
export interface JitsiClassroomProps {
|
|
roomName: string;
|
|
tenantSlug: string;
|
|
jwtToken?: string;
|
|
domain?: string;
|
|
displayName?: string;
|
|
email?: string;
|
|
avatarUrl?: string;
|
|
isHost?: boolean;
|
|
onJoined?: (event: any) => void;
|
|
onLeft?: () => void;
|
|
}
|
|
|
|
export default function JitsiClassroom({
|
|
roomName,
|
|
tenantSlug,
|
|
jwtToken,
|
|
domain = 'jitsi.my127001.ca',
|
|
displayName = 'Participant',
|
|
email,
|
|
avatarUrl,
|
|
isHost = false,
|
|
onJoined,
|
|
onLeft,
|
|
}: JitsiClassroomProps) {
|
|
const router = useRouter();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const jitsiApiRef = useRef<any>(null);
|
|
const [scriptLoaded, setScriptLoaded] = useState(false);
|
|
const [scriptError, setScriptError] = useState(false);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [participantsCount, setParticipantsCount] = useState(1);
|
|
|
|
const handleExit = useCallback(() => {
|
|
if (jitsiApiRef.current) {
|
|
try {
|
|
jitsiApiRef.current.dispose();
|
|
} catch (e) {
|
|
console.error('Error disposing Jitsi API:', e);
|
|
}
|
|
jitsiApiRef.current = null;
|
|
}
|
|
onLeft?.();
|
|
router.push(`/${tenantSlug}/classes`);
|
|
}, [router, tenantSlug, onLeft]);
|
|
|
|
const initJitsi = useCallback(() => {
|
|
if (!window.JitsiMeetExternalAPI || !containerRef.current || jitsiApiRef.current) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Clear container before mounting
|
|
containerRef.current.innerHTML = '';
|
|
|
|
const options = {
|
|
roomName,
|
|
parentNode: containerRef.current,
|
|
width: '100%',
|
|
height: '100%',
|
|
jwt: jwtToken,
|
|
userInfo: {
|
|
displayName,
|
|
email,
|
|
},
|
|
configOverwrite: {
|
|
...DEFAULT_JITSI_CONFIG_OVERRIDES,
|
|
// Host vs Participant controls
|
|
startWithAudioMuted: !isHost,
|
|
startWithVideoMuted: false,
|
|
},
|
|
interfaceConfigOverwrite: {
|
|
...DEFAULT_JITSI_INTERFACE_OVERRIDES,
|
|
},
|
|
};
|
|
|
|
const api = new window.JitsiMeetExternalAPI(domain, options);
|
|
jitsiApiRef.current = api;
|
|
|
|
// Event Listeners
|
|
api.addEventListener('videoConferenceJoined', (event: any) => {
|
|
setIsConnected(true);
|
|
onJoined?.(event);
|
|
});
|
|
|
|
api.addEventListener('videoConferenceLeft', () => {
|
|
setIsConnected(false);
|
|
handleExit();
|
|
});
|
|
|
|
api.addEventListener('participantJoined', (event: any) => {
|
|
setParticipantsCount((prev) => prev + 1);
|
|
});
|
|
|
|
api.addEventListener('participantLeft', () => {
|
|
setParticipantsCount((prev) => Math.max(1, prev - 1));
|
|
});
|
|
|
|
api.addEventListener('readyToClose', () => {
|
|
handleExit();
|
|
});
|
|
|
|
// Set avatar if provided
|
|
if (avatarUrl) {
|
|
api.executeCommand('avatarUrl', avatarUrl);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to initialize JitsiMeetExternalAPI:', err);
|
|
setScriptError(true);
|
|
}
|
|
}, [
|
|
roomName,
|
|
jwtToken,
|
|
domain,
|
|
displayName,
|
|
email,
|
|
avatarUrl,
|
|
isHost,
|
|
onJoined,
|
|
handleExit,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (scriptLoaded) {
|
|
initJitsi();
|
|
}
|
|
|
|
return () => {
|
|
if (jitsiApiRef.current) {
|
|
try {
|
|
jitsiApiRef.current.dispose();
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
jitsiApiRef.current = null;
|
|
}
|
|
};
|
|
}, [scriptLoaded, initJitsi]);
|
|
|
|
return (
|
|
<div className="relative w-full h-full min-h-0 min-w-0 flex-1 flex flex-col bg-[#121212] rounded-xl overflow-hidden border border-neutral-800 shadow-2xl">
|
|
{/* External Jitsi Meet API Script */}
|
|
<Script
|
|
src={`https://${domain}/external_api.js`}
|
|
strategy="afterInteractive"
|
|
onReady={() => setScriptLoaded(true)}
|
|
onError={() => {
|
|
console.warn(`Could not load external_api.js from https://${domain}, activating iframe fallback mode`);
|
|
setScriptError(true);
|
|
}}
|
|
/>
|
|
|
|
{/* Classroom Status Top Bar */}
|
|
<div className="shrink-0 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 sm:px-4 py-2.5 bg-neutral-900 border-b border-neutral-800 text-xs">
|
|
<div className="flex min-w-0 flex-[1_1_16rem] items-center gap-2 sm:gap-3">
|
|
<span className="shrink-0 flex items-center gap-1.5 font-medium text-neutral-200">
|
|
<span
|
|
className={`w-2.5 h-2.5 rounded-full ${
|
|
isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-amber-400'
|
|
}`}
|
|
/>
|
|
{isConnected ? 'LIVE CLASSROOM' : 'CONNECTING...'}
|
|
</span>
|
|
<span className="hidden sm:inline text-neutral-500">|</span>
|
|
<span className="hidden sm:block min-w-0 truncate text-neutral-400 font-mono" title={roomName}>Room: {roomName}</span>
|
|
{isHost && (
|
|
<span className="shrink-0 px-2 py-0.5 rounded text-[10px] bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold">
|
|
HOST / INSTRUCTOR
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 flex items-center gap-3">
|
|
<span className="hidden sm:inline text-neutral-400">
|
|
{participantsCount} participant{participantsCount !== 1 ? 's' : ''}
|
|
</span>
|
|
<button
|
|
onClick={handleExit}
|
|
className="px-3 py-1 bg-red-600/80 hover:bg-red-600 text-white font-medium rounded transition"
|
|
>
|
|
Leave Class
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Primary Video Container */}
|
|
<div className="relative flex-1 min-h-0 min-w-0 w-full overflow-hidden bg-[#121212]">
|
|
{/* Loading overlay when waiting for API */}
|
|
{!scriptLoaded && !scriptError && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#121212] z-10 text-neutral-300">
|
|
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin mb-3"></div>
|
|
<p className="text-sm font-medium">Initializing secure live classroom...</p>
|
|
<p className="text-xs text-neutral-500 mt-1">Connecting to {domain}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Script Fallback Direct Iframe Mode */}
|
|
{scriptError ? (
|
|
<iframe
|
|
src={`https://${domain}/${encodeURIComponent(roomName)}${jwtToken ? `?jwt=${encodeURIComponent(jwtToken)}` : ''}#config.prejoinPageEnabled=false&config.disableThirdPartyRequests=true`}
|
|
allow="camera; microphone; display-capture; autoplay; clipboard-write"
|
|
className="absolute inset-0 block w-full h-full border-0"
|
|
title="Jitsi Classroom Live Session"
|
|
/>
|
|
) : (
|
|
<div ref={containerRef} className="absolute inset-0 w-full h-full" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|