useClassroom.ts 30 KB

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