useClassroom.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953
  1. import { useState, useEffect, useRef } from 'react';
  2. import { useParams } from 'react-router';
  3. // @ts-types="../../../share/aliyun-rtc-sdk.d.ts"
  4. import AliRtcEngine, { AliRtcSubscribeState, AliRtcVideoTrack } from 'aliyun-rtc-sdk';
  5. import { toast } from 'react-toastify';
  6. export enum Role {
  7. Teacher = 'admin',
  8. Student = 'student'
  9. }
  10. // 从SDK中提取需要的类型和枚举
  11. type ImEngine = InstanceType<typeof AliVCInteraction.ImEngine>;
  12. type ImGroupManager = AliVCInteraction.AliVCIMGroupManager;
  13. type ImMessageManager = AliVCInteraction.AliVCIMMessageManager;
  14. type ImLogLevel = AliVCInteraction.ImLogLevel;
  15. type ImMessageLevel = AliVCInteraction.ImMessageLevel;
  16. const { ERROR } = AliVCInteraction.ImLogLevel;
  17. const { NORMAL, HIGH } = AliVCInteraction.ImMessageLevel;
  18. interface ImUser {
  19. userId: string;
  20. userExtension?: string;
  21. }
  22. interface ImGroupMessage {
  23. groupId: string;
  24. type: number;
  25. data: string;
  26. sender?: ImUser;
  27. timestamp?: number;
  28. }
  29. // 互动消息类型
  30. enum InteractionAction {
  31. HandUp = 'hand_up',
  32. CancelHandUp = 'cancel_hand_up',
  33. AnswerHandUp = 'answer_hand_up'
  34. }
  35. interface InteractionMessage {
  36. action: InteractionAction;
  37. studentId: string;
  38. studentName?: string;
  39. timestamp?: number;
  40. question?: string;
  41. }
  42. interface HandUpRequest {
  43. studentId: string;
  44. studentName?: string;
  45. timestamp: number;
  46. question?: string;
  47. }
  48. interface Question {
  49. studentId: string;
  50. studentName?: string;
  51. question: string;
  52. timestamp: number;
  53. }
  54. export enum ClassStatus {
  55. NOT_STARTED = 'not_started',
  56. IN_PROGRESS = 'in_progress',
  57. ENDED = 'ended'
  58. }
  59. // 配置信息
  60. const IM_APP_ID = '4c2ab5e1b1b0';
  61. const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
  62. const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
  63. const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
  64. const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
  65. export const useClassroom = () => {
  66. // 状态管理
  67. const [userId, setUserId] = useState<string>('');
  68. const [isCameraOn, setIsCameraOn] = useState<boolean>(false);
  69. const [isAudioOn, setIsAudioOn] = useState<boolean>(false);
  70. const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false);
  71. const [className, setClassName] = useState<string>('');
  72. const [role, setRole] = useState<Role | undefined>();
  73. const [classId, setClassId] = useState<string>('');
  74. const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
  75. const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
  76. const [msgText, setMsgText] = useState<string>('');
  77. const [messageList, setMessageList] = useState<string[]>([]);
  78. const [errorMessage, setErrorMessage] = useState<string>('');
  79. const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
  80. const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
  81. const [questions, setQuestions] = useState<Question[]>([]);
  82. const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
  83. const [shareLink, setShareLink] = useState<string>('');
  84. const [showCameraOverlay, setShowCameraOverlay] = useState<boolean>(true);
  85. // SDK实例
  86. const imEngine = useRef<ImEngine | null>(null);
  87. const imGroupManager = useRef<ImGroupManager | null>(null);
  88. const imMessageManager = useRef<ImMessageManager | null>(null);
  89. const aliRtcEngine = useRef<AliRtcEngine | null>(null);
  90. const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
  91. const remoteScreenContainer = useRef<HTMLDivElement>(null); // 主屏幕共享容器(重命名)
  92. const remoteCameraContainer = useRef<HTMLDivElement>(null); // 摄像头小窗容器
  93. // 辅助函数
  94. const showMessage = (text: string): void => {
  95. setMessageList((prevMessageList) => [...prevMessageList, text])
  96. };
  97. const showToast = (type: 'info' | 'success' | 'error', message: string): void => {
  98. toast[type](message);
  99. };
  100. const hex = (buffer: ArrayBuffer): string => {
  101. const hexCodes = [];
  102. const view = new DataView(buffer);
  103. for (let i = 0; i < view.byteLength; i += 4) {
  104. const value = view.getUint32(i);
  105. const stringValue = value.toString(16);
  106. const padding = '00000000';
  107. const paddedValue = (padding + stringValue).slice(-padding.length);
  108. hexCodes.push(paddedValue);
  109. }
  110. return hexCodes.join('');
  111. };
  112. const generateToken = async (
  113. appId: string,
  114. appKey: string,
  115. channelId: string,
  116. userId: string,
  117. timestamp: number
  118. ): Promise<string> => {
  119. const encoder = new TextEncoder();
  120. const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);
  121. const hash = await crypto.subtle.digest('SHA-256', data);
  122. return hex(hash);
  123. };
  124. const generateImToken = async (userId: string, role: string): Promise<string> => {
  125. const nonce = 'AK_4';
  126. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  127. const pendingShaStr = `${IM_APP_ID}${IM_APP_KEY}${userId}${nonce}${timestamp}${role}`;
  128. const encoder = new TextEncoder();
  129. const data = encoder.encode(pendingShaStr);
  130. const hash = await crypto.subtle.digest('SHA-256', data);
  131. return hex(hash);
  132. };
  133. // 事件监听函数
  134. const listenImEvents = (): void => {
  135. if (!imEngine.current) return;
  136. if (!role) return;
  137. imEngine.current.on('connectsuccess', () => {
  138. showMessage('IM连接成功');
  139. });
  140. imEngine.current.on('disconnect', async (code: number) => {
  141. showMessage(`IM断开连接: ${code}`);
  142. // 自动重连
  143. try {
  144. const imToken = await generateImToken(userId, role);
  145. await imEngine.current!.login({
  146. user: {
  147. userId,
  148. userExtension: '{}'
  149. },
  150. userAuth: {
  151. nonce: 'AK_4',
  152. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  153. token: imToken,
  154. role
  155. }
  156. });
  157. showMessage('IM自动重连成功');
  158. } catch (err: unknown) {
  159. const error = err as Error;
  160. showMessage(`IM自动重连失败: ${error.message}`);
  161. }
  162. });
  163. };
  164. const listenGroupEvents = (): void => {
  165. if (!imGroupManager.current) return;
  166. imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
  167. showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
  168. });
  169. };
  170. const listenMessageEvents = (): void => {
  171. if (!imMessageManager.current) return;
  172. imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
  173. if (msg.type === 88889) { // 课堂状态消息
  174. try {
  175. const data = JSON.parse(msg.data);
  176. if (data.action === 'start_class') {
  177. setClassStatus(ClassStatus.IN_PROGRESS);
  178. showMessage('老师已开始上课');
  179. } else if (data.action === 'end_class') {
  180. setClassStatus(ClassStatus.ENDED);
  181. showMessage('老师已结束上课');
  182. }
  183. } catch (err) {
  184. console.error('解析课堂状态消息失败', err);
  185. }
  186. } else if (msg.type === 88890) { // 静音指令
  187. try {
  188. const data = JSON.parse(msg.data);
  189. if (data.action === 'toggle_mute' && data.userId === userId) {
  190. showMessage(data.mute ? '你已被老师静音' : '老师已取消你的静音');
  191. }
  192. } catch (err) {
  193. console.error('解析静音指令失败', err);
  194. }
  195. } else if (msg.type === 88891) { // 举手消息
  196. try {
  197. const data = JSON.parse(msg.data) as InteractionMessage;
  198. if (data.action === InteractionAction.HandUp) {
  199. const handUpData: HandUpRequest = {
  200. ...data,
  201. timestamp: data.timestamp || Date.now()
  202. };
  203. setHandUpList([...handUpList, handUpData]);
  204. showMessage(`${data.studentName || data.studentId} 举手了`);
  205. } else if (data.action === InteractionAction.CancelHandUp) {
  206. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  207. }
  208. } catch (err) {
  209. console.error('解析举手消息失败', err);
  210. }
  211. } else if (msg.type === 88892) { // 问题消息
  212. try {
  213. const data = JSON.parse(msg.data) as {question: string};
  214. if (typeof data.question === 'string') {
  215. const question: Question = {
  216. studentId: msg.sender?.userId || 'unknown',
  217. studentName: (() => {
  218. try {
  219. return msg.sender?.userExtension ? JSON.parse(msg.sender.userExtension)?.nickname : null;
  220. } catch {
  221. return null;
  222. }
  223. })() || msg.sender?.userId || '未知用户',
  224. question: data.question,
  225. timestamp: msg.timestamp || Date.now()
  226. };
  227. setQuestions([...questions, question]);
  228. }
  229. showMessage(`收到问题: ${data.question}`);
  230. } catch (err) {
  231. console.error('解析问题消息失败', err);
  232. }
  233. } else if (msg.type === 88893) { // 应答消息
  234. try {
  235. const data = JSON.parse(msg.data) as InteractionMessage;
  236. if (data.action === InteractionAction.AnswerHandUp && data.studentId === userId) {
  237. showMessage('老师已应答你的举手');
  238. setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
  239. }
  240. } catch (err) {
  241. console.error('解析应答消息失败', err);
  242. }
  243. } else if (msg.type === 88888) { // 普通文本消息
  244. showMessage(`${msg.sender?.userId || '未知用户'}: ${msg.data}`);
  245. }
  246. });
  247. };
  248. // RTC相关函数
  249. const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
  250. const vid = `${type}_${userId}`;
  251. const el = remoteVideoElMap.current[vid];
  252. if (el) {
  253. aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
  254. el.pause();
  255. // 根据流类型从不同容器移除
  256. if (type === 'camera') {
  257. remoteCameraContainer.current?.removeChild(el);
  258. } else {
  259. remoteScreenContainer.current?.removeChild(el);
  260. }
  261. delete remoteVideoElMap.current[vid];
  262. }
  263. };
  264. const listenRtcEvents = () => {
  265. if (!aliRtcEngine.current) return;
  266. showMessage('注册rtc事件监听')
  267. aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
  268. showMessage(`用户 ${userId} 加入课堂`);
  269. console.log('用户上线通知:', userId);
  270. });
  271. aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
  272. showMessage(`用户 ${userId} 离开课堂`);
  273. console.log('用户下线通知:', userId);
  274. removeRemoteVideo(userId, 'camera');
  275. removeRemoteVideo(userId, 'screen');
  276. });
  277. aliRtcEngine.current.on('videoSubscribeStateChanged', (
  278. userId: string,
  279. oldState: AliRtcSubscribeState,
  280. newState: AliRtcSubscribeState,
  281. interval: number,
  282. channelId: string
  283. ) => {
  284. console.log(`视频订阅状态变化: 用户 ${userId}, 旧状态 ${oldState}, 新状态 ${newState}`);
  285. switch(newState) {
  286. case 3: // 订阅成功
  287. try {
  288. console.log('开始创建远程视频元素');
  289. if (remoteVideoElMap.current[`camera_${userId}`]) {
  290. console.log(`用户 ${userId} 的视频元素已存在`);
  291. return;
  292. }
  293. const video = document.createElement('video');
  294. video.autoplay = true;
  295. video.playsInline = true;
  296. video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
  297. if (!remoteCameraContainer.current) {
  298. console.error('摄像头视频容器未找到');
  299. return;
  300. }
  301. remoteCameraContainer.current.appendChild(video);
  302. remoteVideoElMap.current[`camera_${userId}`] = video;
  303. aliRtcEngine.current!.setRemoteViewConfig(
  304. video,
  305. userId,
  306. AliRtcVideoTrack.AliRtcVideoTrackCamera
  307. );
  308. console.log(`已订阅用户 ${userId} 的视频流`);
  309. showMessage(`已显示用户 ${userId} 的视频`);
  310. } catch (err) {
  311. console.error(`订阅用户 ${userId} 视频流失败:`, err);
  312. showMessage(`订阅用户 ${userId} 视频流失败`);
  313. }
  314. break;
  315. case 1: // 取消订阅
  316. console.log(`取消订阅用户 ${userId} 的视频流`);
  317. showMessage(`取消订阅用户 ${userId} 的视频流`);
  318. removeRemoteVideo(userId, 'camera');
  319. break;
  320. case 2: // 订阅中
  321. console.log(`正在订阅用户 ${userId} 的视频流...`);
  322. break;
  323. default:
  324. console.warn(`未知订阅状态: ${newState}`);
  325. }
  326. });
  327. aliRtcEngine.current.on('screenShareSubscribeStateChanged', (
  328. userId: string,
  329. oldState: AliRtcSubscribeState,
  330. newState: AliRtcSubscribeState,
  331. elapseSinceLastState: number,
  332. channel: string
  333. ) => {
  334. console.log(`屏幕分享订阅状态变更:uid=${userId}, oldState=${oldState}, newState=${newState}`);
  335. switch(newState) {
  336. case 3: // 订阅成功
  337. try {
  338. console.log('开始创建屏幕分享视频元素');
  339. if (remoteVideoElMap.current[`screen_${userId}`]) {
  340. console.log(`用户 ${userId} 的屏幕分享元素已存在`);
  341. return;
  342. }
  343. const video = document.createElement('video');
  344. video.autoplay = true;
  345. video.playsInline = true;
  346. video.className = 'w-full h-full bg-black';
  347. if (!remoteScreenContainer.current) {
  348. console.error('屏幕共享容器未找到');
  349. return;
  350. }
  351. remoteScreenContainer.current.appendChild(video);
  352. remoteVideoElMap.current[`screen_${userId}`] = video;
  353. aliRtcEngine.current!.setRemoteViewConfig(
  354. video,
  355. userId,
  356. AliRtcVideoTrack.AliRtcVideoTrackScreen
  357. );
  358. console.log(`已订阅用户 ${userId} 的屏幕分享流`);
  359. showMessage(`已显示用户 ${userId} 的屏幕分享`);
  360. } catch (err) {
  361. console.error(`订阅用户 ${userId} 屏幕分享流失败:`, err);
  362. showMessage(`订阅用户 ${userId} 屏幕分享流失败`);
  363. }
  364. break;
  365. case 1: // 取消订阅
  366. console.log(`取消订阅用户 ${userId} 的屏幕分享流`);
  367. showMessage(`取消订阅用户 ${userId} 的屏幕分享流`);
  368. removeRemoteVideo(userId, 'screen');
  369. break;
  370. case 2: // 订阅中
  371. console.log(`正在订阅用户 ${userId} 的屏幕分享流...`);
  372. break;
  373. default:
  374. console.warn(`未知屏幕分享订阅状态: ${newState}`);
  375. }
  376. });
  377. };
  378. // 课堂操作方法
  379. const login = async (userId: string, role: Role): Promise<void> => {
  380. if(!role) {
  381. showToast('error', '角色不存在');
  382. return;
  383. }
  384. try {
  385. const { ImEngine: ImEngineClass } = window.AliVCInteraction;
  386. imEngine.current = ImEngineClass.createEngine();
  387. await imEngine.current.init({
  388. deviceId: 'xxxx',
  389. appId: IM_APP_ID,
  390. appSign: IM_APP_SIGN,
  391. logLevel: ERROR,
  392. });
  393. const imToken = await generateImToken(userId, role);
  394. await imEngine.current.login({
  395. user: {
  396. userId,
  397. userExtension: '{}'
  398. },
  399. userAuth: {
  400. nonce: 'AK_4',
  401. timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
  402. token: imToken,
  403. role
  404. }
  405. });
  406. aliRtcEngine.current = AliRtcEngine.getInstance();
  407. AliRtcEngine.setLogLevel(0);
  408. listenImEvents();
  409. listenRtcEvents();
  410. setIsLoggedIn(true);
  411. setErrorMessage('');
  412. showToast('success', '登录成功');
  413. } catch (err: any) {
  414. setErrorMessage(`登录失败: ${err.message}`);
  415. showToast('error', '登录失败');
  416. }
  417. };
  418. const joinClass = async (classId: string): Promise<void> => {
  419. if (!imEngine.current || !aliRtcEngine.current) return;
  420. // // 优先使用URL参数中的classId和role
  421. // const { id: pathClassId, role: pathRole } = useParams();
  422. // const finalClassId = (classId || pathClassId) as string;
  423. // if (pathRole && ['teacher', 'student'].includes(pathRole)) {
  424. // setRole(pathRole === 'teacher' ? Role.Teacher : Role.Student);
  425. // }
  426. // if (!finalClassId) {
  427. // setErrorMessage('课堂ID不能为空');
  428. // showToast('error', '请输入有效的课堂ID');
  429. // return;
  430. // }
  431. try {
  432. const gm = imEngine.current.getGroupManager();
  433. const mm = imEngine.current.getMessageManager();
  434. imGroupManager.current = gm || null;
  435. imMessageManager.current = mm || null;
  436. await gm!.joinGroup(classId);
  437. listenGroupEvents();
  438. listenMessageEvents();
  439. await joinRtcChannel(classId);
  440. buildShareLink(classId)
  441. setIsJoinedClass(true);
  442. setErrorMessage('');
  443. showToast('success', '加入课堂成功');
  444. } catch (err: any) {
  445. setErrorMessage(`加入课堂失败: ${err.message}`);
  446. showToast('error', '加入课堂失败');
  447. if (imGroupManager.current) {
  448. try {
  449. await imGroupManager.current.leaveGroup(classId);
  450. } catch (leaveErr) {
  451. console.error('离开IM群组失败:', leaveErr);
  452. }
  453. }
  454. }
  455. };
  456. const leaveClass = async (): Promise<void> => {
  457. try {
  458. if (imGroupManager.current && classId) {
  459. await imGroupManager.current.leaveGroup(classId);
  460. }
  461. if (aliRtcEngine.current) {
  462. await leaveRtcChannel();
  463. }
  464. setIsJoinedClass(false);
  465. showToast('info', '已离开课堂');
  466. } catch (err) {
  467. console.error('离开课堂失败:', err);
  468. showToast('error', '离开课堂时发生错误');
  469. }
  470. };
  471. const sendMessage = async (): Promise<void> => {
  472. if (!imMessageManager.current || !classId) return;
  473. try {
  474. await imMessageManager.current.sendGroupMessage({
  475. groupId: classId,
  476. data: msgText,
  477. type: 88888,
  478. level: NORMAL,
  479. });
  480. setMsgText('');
  481. setErrorMessage('');
  482. } catch (err: any) {
  483. setErrorMessage(`消息发送失败: ${err.message}`);
  484. }
  485. };
  486. const startClass = async (): Promise<void> => {
  487. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  488. try {
  489. await imMessageManager.current.sendGroupMessage({
  490. groupId: classId,
  491. data: JSON.stringify({ action: 'start_class' }),
  492. type: 88889,
  493. level: HIGH,
  494. });
  495. setClassStatus(ClassStatus.IN_PROGRESS);
  496. showToast('success', '课堂已开始');
  497. } catch (err: any) {
  498. setErrorMessage(`开始上课失败: ${err.message}`);
  499. }
  500. };
  501. const endClass = async (): Promise<void> => {
  502. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  503. try {
  504. await imMessageManager.current.sendGroupMessage({
  505. groupId: classId,
  506. data: JSON.stringify({ action: 'end_class' }),
  507. type: 88889,
  508. level: HIGH,
  509. });
  510. setClassStatus(ClassStatus.ENDED);
  511. showToast('success', '课堂已结束');
  512. try {
  513. await leaveRtcChannel();
  514. } catch (err: any) {
  515. console.error('离开RTC频道失败:', err);
  516. showToast('error', '离开RTC频道失败');
  517. }
  518. } catch (err: any) {
  519. setErrorMessage(`结束上课失败: ${err.message}`);
  520. }
  521. };
  522. const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
  523. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  524. try {
  525. await imMessageManager.current.sendGroupMessage({
  526. groupId: classId,
  527. data: JSON.stringify({
  528. action: 'toggle_mute',
  529. userId,
  530. mute
  531. }),
  532. type: 88890,
  533. level: HIGH,
  534. });
  535. showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
  536. } catch (err: any) {
  537. setErrorMessage(`操作失败: ${err.message}`);
  538. }
  539. };
  540. const buildShareLink = (classId: string) => {
  541. const getBaseUrl = () => {
  542. const protocol = window.location.protocol;
  543. const host = window.location.host;
  544. return `${protocol}//${host}`;
  545. }
  546. // const baseUrl = window.location.href.split('?')[0].replace(/\/[^/]*$/, '');
  547. const baseUrl = getBaseUrl();
  548. setShareLink(`${baseUrl}/mobile/classroom/${classId}/student`);
  549. }
  550. const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
  551. if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
  552. showToast('error', '只有老师可以创建课堂');
  553. return null;
  554. }
  555. try {
  556. const groupManager = imEngine.current.getGroupManager();
  557. if (!groupManager) {
  558. throw new Error('群组管理器未初始化');
  559. }
  560. showToast('info', '正在创建课堂...');
  561. const response = await groupManager.createGroup({
  562. groupName: className,
  563. groupMeta: JSON.stringify({
  564. classType: 'interactive',
  565. creator: userId,
  566. createdAt: Date.now(),
  567. maxMembers
  568. })
  569. });
  570. if (!response?.groupId) {
  571. throw new Error('创建群组失败: 未返回群组ID');
  572. }
  573. try {
  574. await groupManager.joinGroup(response.groupId);
  575. showToast('success', '课堂创建并加入成功');
  576. showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
  577. setClassId(response.groupId);
  578. setIsJoinedClass(true);
  579. const messageManager = imEngine.current.getMessageManager();
  580. if (messageManager) {
  581. imMessageManager.current = messageManager;
  582. listenMessageEvents();
  583. }
  584. await joinRtcChannel(response.groupId);
  585. // const baseUrl = window.location.href.split('?')[0].replace(/\/[^/]*$/, '');
  586. // setShareLink(`${baseUrl}/mobile/classroom/${response.groupId}/student`);
  587. buildShareLink(response.groupId)
  588. return response.groupId;
  589. } catch (joinErr: any) {
  590. throw new Error(`创建成功但加入失败: ${joinErr.message}`);
  591. }
  592. } catch (err: any) {
  593. const errorMsg = err.message.includes('alreadyExist')
  594. ? '课堂已存在'
  595. : `课堂创建失败: ${err.message}`;
  596. setErrorMessage(errorMsg);
  597. showToast('error', errorMsg);
  598. return null;
  599. }
  600. };
  601. const joinRtcChannel = async (classId: string, publishOptions?: {
  602. publishVideo?: boolean
  603. publishAudio?: boolean
  604. publishScreen?: boolean
  605. }) => {
  606. if (!aliRtcEngine.current) return;
  607. const {
  608. publishVideo = false,
  609. publishAudio = false,
  610. publishScreen = false,
  611. } = publishOptions || {};
  612. const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
  613. const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
  614. await aliRtcEngine.current.publishLocalVideoStream(publishVideo);
  615. await aliRtcEngine.current.publishLocalAudioStream(publishAudio);
  616. await aliRtcEngine.current.publishLocalScreenShareStream(publishScreen);
  617. await aliRtcEngine.current.joinChannel(
  618. {
  619. channelId: classId,
  620. userId,
  621. appId: RTC_APP_ID,
  622. token,
  623. timestamp,
  624. },
  625. userId
  626. );
  627. };
  628. const leaveRtcChannel = async () => {
  629. if (!aliRtcEngine.current) return;
  630. await aliRtcEngine.current.leaveChannel();
  631. };
  632. // 切换摄像头状态
  633. const toggleCamera = async () => {
  634. if(!aliRtcEngine.current?.isInCall){
  635. showToast('error', '先加入课堂');
  636. return;
  637. }
  638. try {
  639. if (isCameraOn) {
  640. await aliRtcEngine.current?.stopPreview();
  641. await aliRtcEngine.current?.enableLocalVideo(false)
  642. await aliRtcEngine.current?.publishLocalVideoStream(false)
  643. } else {
  644. await aliRtcEngine.current?.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
  645. await aliRtcEngine.current?.enableLocalVideo(true)
  646. await aliRtcEngine.current?.startPreview();
  647. await aliRtcEngine.current?.publishLocalVideoStream(true)
  648. }
  649. await aliRtcEngine.current?.startAndPublishDefaultDevices()
  650. setIsCameraOn(!isCameraOn);
  651. } catch (err) {
  652. console.error('切换摄像头状态失败:', err);
  653. showToast('error', '切换摄像头失败');
  654. }
  655. };
  656. // 切换音频状态
  657. const toggleAudio = async () => {
  658. if(!aliRtcEngine.current?.isInCall){
  659. showToast('error', '先加入课堂');
  660. return;
  661. }
  662. try {
  663. if (isAudioOn) {
  664. await aliRtcEngine.current?.stopAudioCapture()
  665. await aliRtcEngine.current?.publishLocalAudioStream(false);
  666. } else {
  667. await aliRtcEngine.current?.publishLocalAudioStream(true);
  668. }
  669. await aliRtcEngine.current?.startAndPublishDefaultDevices();
  670. setIsAudioOn(!isAudioOn);
  671. } catch (err) {
  672. console.error('切换麦克风状态失败:', err);
  673. showToast('error', '切换麦克风失败');
  674. }
  675. };
  676. // 切换屏幕分享状态
  677. const toggleScreenShare = async () => {
  678. if(!aliRtcEngine.current?.isInCall){
  679. showToast('error', '先加入课堂');
  680. return;
  681. }
  682. try {
  683. if (isScreenSharing) {
  684. await aliRtcEngine.current?.publishLocalScreenShareStream(false)
  685. await aliRtcEngine.current?.stopScreenShare()
  686. } else {
  687. await aliRtcEngine.current?.publishLocalScreenShareStream(true)
  688. await aliRtcEngine.current?.setLocalViewConfig(
  689. 'screenPreviewer',
  690. AliRtcVideoTrack.AliRtcVideoTrackScreen
  691. );
  692. }
  693. await aliRtcEngine.current?.startAndPublishDefaultDevices()
  694. setIsScreenSharing(!isScreenSharing);
  695. } catch (err) {
  696. console.error('切换屏幕分享失败:', err);
  697. showToast('error', '切换屏幕分享失败');
  698. }
  699. };
  700. const handUp = async (question?: string): Promise<void> => {
  701. if (!imMessageManager.current || !classId || role !== 'student') return;
  702. try {
  703. await imMessageManager.current.sendGroupMessage({
  704. groupId: classId,
  705. data: JSON.stringify({
  706. action: 'hand_up',
  707. studentId: userId,
  708. timestamp: Date.now(),
  709. question
  710. }),
  711. type: 88891,
  712. level: NORMAL,
  713. });
  714. } catch (err: any) {
  715. setErrorMessage(`举手失败: ${err.message}`);
  716. }
  717. };
  718. const muteStudent = async (studentId: string): Promise<void> => {
  719. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  720. try {
  721. await imMessageManager.current.sendGroupMessage({
  722. groupId: classId,
  723. data: JSON.stringify({
  724. action: 'toggle_mute',
  725. userId: studentId,
  726. mute: true
  727. }),
  728. type: 88890,
  729. level: HIGH,
  730. });
  731. showToast('info', `已静音学生 ${studentId}`);
  732. } catch (err: any) {
  733. setErrorMessage(`静音失败: ${err.message}`);
  734. }
  735. };
  736. const kickStudent = async (studentId: string): Promise<void> => {
  737. if (!imGroupManager.current || !classId || role !== Role.Teacher) return;
  738. try {
  739. await imGroupManager.current.leaveGroup(classId);
  740. showToast('info', `已移出学生 ${studentId}`);
  741. } catch (err: any) {
  742. setErrorMessage(`移出失败: ${err.message}`);
  743. }
  744. };
  745. const answerHandUp = async (studentId: string): Promise<void> => {
  746. if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
  747. try {
  748. await imMessageManager.current.sendGroupMessage({
  749. groupId: classId,
  750. data: JSON.stringify({
  751. action: 'answer_hand_up',
  752. studentId
  753. }),
  754. type: 88893,
  755. level: HIGH,
  756. });
  757. showToast('info', `已应答学生 ${studentId} 的举手`);
  758. } catch (err: any) {
  759. setErrorMessage(`应答失败: ${err.message}`);
  760. }
  761. };
  762. const sendQuestion = async (question: string): Promise<void> => {
  763. if (!imMessageManager.current || !classId) return;
  764. try {
  765. await imMessageManager.current.sendGroupMessage({
  766. groupId: classId,
  767. data: question,
  768. type: 88892,
  769. level: NORMAL,
  770. });
  771. } catch (err: any) {
  772. setErrorMessage(`问题发送失败: ${err.message}`);
  773. }
  774. };
  775. // 清理资源
  776. useEffect(() => {
  777. return () => {
  778. if (imGroupManager.current) {
  779. imGroupManager.current.removeAllListeners();
  780. }
  781. if (imMessageManager.current) {
  782. imMessageManager.current.removeAllListeners();
  783. }
  784. if (imEngine.current) {
  785. imEngine.current.removeAllListeners();
  786. }
  787. if (aliRtcEngine.current) {
  788. aliRtcEngine.current.destroy();
  789. }
  790. };
  791. }, []);
  792. return {
  793. // 状态
  794. userId,
  795. setUserId,
  796. isCameraOn,
  797. isAudioOn,
  798. isScreenSharing,
  799. className,
  800. setClassName,
  801. role,
  802. setRole,
  803. classId,
  804. setClassId,
  805. isLoggedIn,
  806. isJoinedClass,
  807. msgText,
  808. setMsgText,
  809. messageList,
  810. errorMessage,
  811. classStatus,
  812. handUpList,
  813. questions,
  814. students,
  815. shareLink,
  816. remoteScreenContainer, // 重命名为remoteScreenContainer
  817. remoteCameraContainer, // 导出摄像头容器ref
  818. showCameraOverlay,
  819. setShowCameraOverlay,
  820. // 方法
  821. login,
  822. joinClass,
  823. leaveClass,
  824. sendMessage,
  825. startClass,
  826. endClass,
  827. toggleMuteMember,
  828. createClass,
  829. toggleCamera,
  830. toggleAudio,
  831. toggleScreenShare,
  832. handUp,
  833. answerHandUp,
  834. sendQuestion,
  835. muteStudent,
  836. kickStudent
  837. };
  838. };