FitnessApp/components/classroom/JitsiClassroom.tsx

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>
);
}