useClassroom.ts 29 KB

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