lms/resources/js/pages/course-player/live-class/zoom-live-class.tsx
2025-12-15 12:26:23 +01:00

358 lines
11 KiB
TypeScript

import { useAuth } from '@/hooks/use-auth';
import { usePage } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
interface Props {
live_class: CourseLiveClass;
watchHistory: WatchHistory;
zoom_sdk_client_id?: string;
zoom_sdk_client_secret?: string;
}
interface MeetingConfig {
role: number;
password: string;
signature: string;
meetingNumber: string;
}
// Zoom Client View SDK type declarations
declare global {
interface Window {
ZoomMtg: {
setZoomJSLib: (path: string, dir: string) => void;
preLoadWasm: () => void;
prepareWebSDK: () => void;
checkSystemRequirements: () => any;
i18n: {
load: (lang: string) => void;
onLoad: (callback: () => void) => void;
};
init: (config: { leaveUrl: string; disableCORP?: boolean; success: () => void; error: (error: any) => void }) => void;
join: (config: {
meetingNumber: string;
userName: string;
signature: string;
userEmail?: string;
passWord?: string;
success: (res: any) => void;
error: (res: any) => void;
}) => void;
leave: () => void;
inMeetingServiceListener: (event: string, callback: (data: any) => void) => void;
getAttendeeslist: (config: any) => void;
getCurrentUser: (config: any) => void;
};
}
}
const ZoomLiveClass = ({ live_class, watchHistory, zoom_sdk_client_id }: Props) => {
const { props } = usePage();
const { auth, translate } = props as any;
const { frontend } = translate;
const { isAdmin } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sdkLoaded, setSdkLoaded] = useState(false);
const initializationRef = useRef(false);
const redirectUrl = isAdmin
? `/dashboard/courses`
: `/play-course/${live_class.course.slug}/${watchHistory.id}/${watchHistory.current_watching_id}`;
// Get meeting info
const meetingInfo = (() => {
if (!live_class.additional_info) return null;
try {
if (typeof live_class.additional_info === 'object') {
return live_class.additional_info;
}
if (typeof live_class.additional_info === 'string') {
return JSON.parse(live_class.additional_info);
}
return null;
} catch (error) {
// Error parsing meeting info
return null;
}
})();
// Load Zoom Client View SDK scripts
const loadZoomSDK = async () => {
// Zoom SDK already loaded
if (window.ZoomMtg) {
setSdkLoaded(true);
return Promise.resolve();
}
// Loading Zoom Client View SDK scripts
// Script loading order is important for Client View SDK
const scripts = [
'https://source.zoom.us/4.0.0/lib/vendor/react.min.js',
'https://source.zoom.us/4.0.0/lib/vendor/react-dom.min.js',
'https://source.zoom.us/4.0.0/lib/vendor/redux.min.js',
'https://source.zoom.us/4.0.0/lib/vendor/redux-thunk.min.js',
'https://source.zoom.us/4.0.0/zoom-meeting-4.0.0.min.js', // Client View SDK
];
for (const scriptSrc of scripts) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = scriptSrc;
script.async = false; // Load in order
script.onload = () => {
resolve();
};
script.onerror = () => {
reject(new Error(`Failed to load script: ${scriptSrc}`));
};
document.head.appendChild(script);
// Add timeout
setTimeout(() => reject(new Error(`Script load timeout: ${scriptSrc}`)), 10000);
});
}
// All Zoom Client View SDK scripts loaded successfully
setSdkLoaded(true);
};
// Fetch signature and meeting config from backend
const fetchMeetingConfig = async () => {
try {
setLoading(true);
const response = await fetch(route('live-class.signature', live_class.id), {
method: 'GET',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return data;
} catch (error) {
setError(error instanceof Error ? error.message : frontend.failed_to_get_meeting_configuration);
return null;
}
};
// Initialize Zoom Client View SDK
const initializeZoomSDK = async (config: MeetingConfig) => {
try {
if (!window.ZoomMtg) {
throw new Error(frontend.zoom_sdk_not_loaded);
}
// Check system requirements
window.ZoomMtg.checkSystemRequirements();
// Clear the React root to avoid conflicts with Zoom's DOM manipulation
const appElement = document.getElementById('app') || document.body;
appElement.innerHTML = '';
// Prepare body for Zoom to take over
document.body.style.margin = '0';
document.body.style.padding = '0';
document.body.style.overflow = 'hidden';
document.body.style.height = '100vh';
document.body.style.width = '100vw';
// Pre-load WASM and prepare SDK
window.ZoomMtg.preLoadWasm();
window.ZoomMtg.prepareWebSDK();
// Load language (default to English)
window.ZoomMtg.i18n.load('en-US');
// Initialize SDK when language is loaded
window.ZoomMtg.i18n.onLoad(() => {
window.ZoomMtg.init({
leaveUrl: window.location.origin + redirectUrl, // Redirect after leaving
disableCORP: !window.crossOriginIsolated, // Required for security
success: () => {
joinMeeting(config);
},
error: (error: any) => {
setError(frontend.failed_to_initialize_meeting + ': ' + (error.message || error));
setLoading(false);
},
});
});
// Set up meeting event listeners
setupMeetingListeners();
} catch (error) {
setError(error instanceof Error ? error.message : frontend.failed_to_initialize_meeting);
setLoading(false);
}
};
// Join the meeting
const joinMeeting = (config: MeetingConfig) => {
window.ZoomMtg.join({
meetingNumber: config.meetingNumber,
userName: auth.user.name,
signature: config.signature,
userEmail: auth.user.email || '',
passWord: config.password,
success: (res: any) => {
// At this point, Zoom has taken over the page completely
// Our React component should step aside
setLoading(false);
// Get current user info
window.ZoomMtg.getCurrentUser({
success: (res: any) => {
// Current user info
},
});
// Get attendees list
window.ZoomMtg.getAttendeeslist({});
},
error: (error: any) => {
setError(frontend.failed_to_join_meeting + ': ' + (error.message || error));
setLoading(false);
},
});
};
// Setup meeting event listeners
const setupMeetingListeners = () => {
// User join event
window.ZoomMtg.inMeetingServiceListener('onUserJoin', (data: any) => {
// User joined
});
// User leave event
window.ZoomMtg.inMeetingServiceListener('onUserLeave', (data: any) => {
// User left
});
// Waiting room event
window.ZoomMtg.inMeetingServiceListener('onUserIsInWaitingRoom', (data: any) => {
// User in waiting room
});
// Meeting status change
window.ZoomMtg.inMeetingServiceListener('onMeetingStatus', (data: any) => {
// Meeting status changed
});
};
// Cleanup function
const cleanup = () => {
if (window.ZoomMtg) {
try {
window.ZoomMtg.leave();
} catch (error) {
// Error leaving meeting during cleanup
}
}
};
// Main initialization useEffect
useEffect(() => {
if (initializationRef.current) return;
initializationRef.current = true;
const initializeMeeting = async () => {
// Check if SDK credentials are available
if (!zoom_sdk_client_id) {
setError(frontend.zoom_sdk_not_configured);
setLoading(false);
return;
}
// Check if meeting info exists
if (!meetingInfo) {
setError(frontend.meeting_information_not_found);
setLoading(false);
return;
}
try {
// Step 1: Load SDK scripts
await loadZoomSDK();
// Step 2: Fetch meeting config
const config = await fetchMeetingConfig();
if (!config) return;
// Step 3: Initialize and join meeting
await initializeZoomSDK(config);
} catch (error) {
setError(error instanceof Error ? error.message : frontend.failed_to_initialize_meeting);
setLoading(false);
}
};
initializeMeeting();
// Cleanup on unmount
return cleanup;
}, []);
// Render loading state
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-900">
<div className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-white"></div>
<h1 className="text-xl font-semibold text-white">{live_class.class_topic}</h1>
<p className="text-gray-300">{!sdkLoaded ? frontend.loading_zoom_sdk : frontend.joining_meeting}</p>
</div>
</div>
);
}
// Render error state
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<div className="max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
<h1 className="mb-4 text-2xl font-bold text-red-600">{frontend.unable_to_join_meeting}</h1>
<p className="text-muted-foreground mb-4">{error}</p>
{/* Fallback: Show direct Zoom link if available */}
{meetingInfo?.join_url && (
<div className="mt-4">
<p className="mb-2 text-sm text-gray-500">{frontend.you_can_join_directly}</p>
<a
href={meetingInfo.join_url}
target="_blank"
rel="noopener noreferrer"
className="inline-block rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
{frontend.open_in_zoom_app}
</a>
</div>
)}
<button onClick={() => window.location.reload()} className="mt-4 rounded bg-gray-600 px-4 py-2 text-white hover:bg-gray-700">
{frontend.try_again}
</button>
</div>
</div>
);
}
return null;
};
export default ZoomLiveClass;