Browse Source

优化 classroom 布局

yourname 7 months ago
parent
commit
ba89f12796

+ 17 - 0
client/mobile/components/Classroom/AuthLayout.tsx

@@ -0,0 +1,17 @@
+import React, { ReactNode } from 'react';
+
+interface AuthLayoutProps {
+  children: ReactNode;
+}
+
+export const AuthLayout = ({ children }: AuthLayoutProps) => {
+  return (
+    <div className="flex flex-col h-screen bg-gray-100">
+      <div className="flex-1 flex items-center justify-center p-4">
+        <div className="w-full max-w-md bg-white rounded-lg shadow p-6">
+          {children}
+        </div>
+      </div>
+    </div>
+  );
+};

+ 161 - 0
client/mobile/components/Classroom/ClassroomLayout.tsx

@@ -0,0 +1,161 @@
+import React, { ReactNode } from 'react';
+import { Role } from './useClassroom.ts';
+import { useClassroomContext } from './ClassroomProvider.tsx';
+import {
+  VideoCameraIcon,
+  CameraIcon,
+  MicrophoneIcon,
+  ShareIcon,
+  ClipboardDocumentIcon,
+  PaperAirplaneIcon
+} from '@heroicons/react/24/outline';
+
+interface ClassroomLayoutProps {
+  children: ReactNode;
+  role: Role;
+}
+
+export const ClassroomLayout = ({ children, role }: ClassroomLayoutProps) => {
+  const [showVideo, setShowVideo] = React.useState(role !== Role.Teacher);
+  const [showShareLink, setShowShareLink] = React.useState(false);
+  const {
+    remoteVideoContainer,
+    isCameraOn,
+    isAudioOn,
+    isScreenSharing,
+    toggleCamera,
+    toggleAudio,
+    toggleScreenShare,
+    messageList,
+    msgText,
+    setMsgText,
+    sendMessage,
+    handUpList,
+    questions,
+    classStatus,
+    shareLink
+  } = useClassroomContext();
+
+  return (
+    <div className="flex flex-col h-screen bg-gray-100">
+      <div className="flex-1 flex">
+        {/* 视频区域 */}
+        {showVideo && (
+          <div className="flex-1 p-4">
+            <div className="bg-white rounded-lg shadow p-4 h-full">
+              <div
+                id="remoteVideoContainer"
+                ref={remoteVideoContainer}
+                className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
+              >
+                {/* 远程视频将在这里动态添加 */}
+              </div>
+            </div>
+          </div>
+        )}
+
+        {/* 消息和控制面板列 */}
+        <div className={`${showVideo ? 'w-96' : 'flex-1'} flex flex-col`}>
+          {/* 控制面板 */}
+          <div className="bg-white shadow-lg p-3 flex flex-col gap-3">
+            <div className="flex flex-wrap gap-2">
+            {role === Role.Teacher && (
+              <button
+                onClick={() => setShowVideo(!showVideo)}
+                className={`p-2 rounded-full ${showVideo ? 'bg-gray-500' : 'bg-gray-300'} text-white`}
+                title={showVideo ? '隐藏视频' : '显示视频'}
+              >
+                <VideoCameraIcon className="w-5 h-5" />
+              </button>
+            )}
+            <button
+              onClick={toggleCamera}
+              className={`p-2 rounded-full ${isCameraOn ? 'bg-green-500' : 'bg-red-500'} text-white`}
+              title={isCameraOn ? '关闭摄像头' : '开启摄像头'}
+            >
+              <CameraIcon className="w-5 h-5" />
+            </button>
+            <button
+              onClick={toggleAudio}
+              className={`p-2 rounded-full ${isAudioOn ? 'bg-green-500' : 'bg-red-500'} text-white`}
+              title={isAudioOn ? '关闭麦克风' : '开启麦克风'}
+            >
+              <MicrophoneIcon className="w-5 h-5" />
+            </button>
+            {role === Role.Teacher && (
+              <button
+                onClick={toggleScreenShare}
+                className={`p-2 rounded-full ${isScreenSharing ? 'bg-green-500' : 'bg-blue-500'} text-white`}
+                title={isScreenSharing ? '停止共享' : '共享屏幕'}
+              >
+                <ShareIcon className="w-5 h-5" />
+              </button>
+            )}
+            {role === Role.Teacher && shareLink && (
+              <button
+                onClick={() => setShowShareLink(!showShareLink)}
+                className="p-2 rounded-full bg-blue-500 text-white"
+                title="分享链接"
+              >
+                <ClipboardDocumentIcon className="w-5 h-5" />
+              </button>
+            )}
+          </div>
+
+          {showShareLink && shareLink && (
+            <div className="bg-blue-50 p-2 rounded">
+              <div className="flex items-center gap-1">
+                <input
+                  type="text"
+                  value={shareLink}
+                  readOnly
+                  className="flex-1 text-xs border rounded px-2 py-1 truncate"
+                />
+                <button
+                  onClick={() => navigator.clipboard.writeText(shareLink)}
+                  className="p-2 bg-blue-500 text-white rounded"
+                  title="复制链接"
+                >
+                  <ClipboardDocumentIcon className="w-4 h-4" />
+                </button>
+              </div>
+            </div>
+          )}
+
+          {/* 角色特定内容 */}
+          <div className="flex-1 overflow-y-auto">
+            {children}
+          </div>
+        </div>
+
+          {/* 消息区域 */}
+          <div className="bg-white shadow-lg p-4 flex-1">
+            <div className="h-full flex flex-col">
+            <div className="flex-1 overflow-y-auto mb-2">
+              {messageList.map((msg, i) => (
+                <div key={i} className="text-sm mb-1">{msg}</div>
+              ))}
+            </div>
+            <div className="relative">
+              <textarea
+                value={msgText}
+                onChange={(e) => setMsgText(e.target.value)}
+                className="w-full border rounded px-2 py-1 pr-10"
+                placeholder="输入消息..."
+                rows={3}
+              />
+              <button
+                onClick={sendMessage}
+                className="absolute right-2 bottom-2 p-1 bg-blue-500 text-white rounded-full"
+              >
+                <PaperAirplaneIcon className="w-5 h-5" />
+              </button>
+            </div>
+            </div>
+          </div>
+
+        </div>
+      </div>
+    </div>
+  );
+};

+ 34 - 0
client/mobile/components/Classroom/ClassroomProvider.tsx

@@ -0,0 +1,34 @@
+import React, { useState, useEffect, useRef, createContext, useContext } from 'react';
+import { useClassroom , Role } from './useClassroom.ts';
+
+type ClassroomContextType = ReturnType<typeof useClassroom>;
+
+const ClassroomContext = createContext<ClassroomContextType | null>(null);
+
+export const ClassroomProvider: React.FC<{children: React.ReactNode}> = ({ children }) => {
+  const classroom = useClassroom();
+
+  useEffect(() => {
+    // 解析URL参数中的classId
+    const params = new URLSearchParams(window.location.search);
+    const classId = params.get('classId');
+    if (classId) {
+      classroom.setClassId(classId);
+      classroom.setRole(Role.Student);
+    }
+  }, []);
+
+  return (
+    <ClassroomContext.Provider value={classroom}>
+      {children}
+    </ClassroomContext.Provider>
+  );
+};
+
+export const useClassroomContext = () => {
+  const context = useContext(ClassroomContext);
+  if (!context) {
+    throw new Error('useClassroomContext must be used within a ClassroomProvider');
+  }
+  return context;
+};

+ 58 - 0
client/mobile/components/Classroom/StudentView.tsx

@@ -0,0 +1,58 @@
+import React, { useState } from 'react';
+import { useClassroomContext } from './ClassroomProvider.tsx';
+import { ClassStatus } from './useClassroom.ts';
+
+export const StudentView = () => {
+  const {
+    handUp,
+    sendQuestion,
+    classStatus,
+    isJoinedClass
+  } = useClassroomContext();
+  const [questionText, setQuestionText] = useState('');
+
+  const handleSendQuestion = () => {
+    if (questionText.trim()) {
+      sendQuestion(questionText);
+      setQuestionText('');
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      {isJoinedClass && (
+        <div className="mt-4 p-4 bg-white rounded-md shadow">
+          <h4 className="text-lg font-medium mb-2">互动功能</h4>
+          <div className="space-y-3">
+            <button
+              type="button"
+              className="w-full px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:bg-gray-400"
+              onClick={() => handUp()}
+              disabled={classStatus !== ClassStatus.IN_PROGRESS}
+            >
+              {classStatus === ClassStatus.IN_PROGRESS ? '举手' : '课堂未开始'}
+            </button>
+            <div className="flex space-x-2">
+              <input
+                type="text"
+                placeholder="输入问题..."
+                className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+                value={questionText}
+                onChange={(e) => setQuestionText(e.target.value)}
+                onKeyPress={(e) => e.key === 'Enter' && handleSendQuestion()}
+              />
+              <button
+                type="button"
+                className="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400"
+                onClick={handleSendQuestion}
+                disabled={!questionText.trim() || classStatus !== ClassStatus.IN_PROGRESS}
+              >
+                提问
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};

+ 80 - 0
client/mobile/components/Classroom/TeacherView.tsx

@@ -0,0 +1,80 @@
+import React from 'react';
+import { useClassroomContext } from './ClassroomProvider.tsx';
+import { ClassStatus } from './useClassroom.ts';
+
+export const TeacherView = () => {
+  const {
+    startClass,
+    endClass,
+    muteStudent,
+    kickStudent,
+    handUpList,
+    questions,
+    classStatus
+  } = useClassroomContext();
+
+  return (
+    <div className="space-y-4">
+
+      <div className="flex space-x-2">
+        <button
+          onClick={startClass}
+          disabled={classStatus === ClassStatus.IN_PROGRESS}
+          className="px-4 py-2 bg-blue-500 text-white rounded disabled:bg-gray-400"
+        >
+          {classStatus === ClassStatus.IN_PROGRESS ? '课堂已开始' : '开始课堂'}
+        </button>
+        <button
+          onClick={endClass}
+          disabled={classStatus !== ClassStatus.IN_PROGRESS}
+          className="px-4 py-2 bg-red-500 text-white rounded disabled:bg-gray-400"
+        >
+          结束课堂
+        </button>
+      </div>
+
+      {handUpList.length > 0 && (
+        <div className="bg-yellow-50 p-3 rounded">
+          <h3 className="font-bold mb-2">举手列表</h3>
+          <ul className="space-y-1">
+            {handUpList.map((student, i) => (
+              <li key={i} className="flex justify-between items-center">
+                <span>{student.studentName || student.studentId}</span>
+                <div className="space-x-2">
+                  <button
+                    onClick={() => muteStudent(student.studentId)}
+                    className="text-xs px-2 py-1 bg-gray-200 rounded"
+                  >
+                    静音
+                  </button>
+                  <button
+                    onClick={() => kickStudent(student.studentId)}
+                    className="text-xs px-2 py-1 bg-red-200 rounded"
+                  >
+                    移出
+                  </button>
+                </div>
+              </li>
+            ))}
+          </ul>
+        </div>
+      )}
+
+      {questions.length > 0 && (
+        <div className="bg-blue-50 p-3 rounded">
+          <h3 className="font-bold mb-2">学生提问</h3>
+          <ul className="space-y-2">
+            {questions.map((q, i) => (
+              <li key={i} className="border-b pb-2">
+                <p className="font-medium">{q.studentName || q.studentId}: {q.question}</p>
+                <p className="text-sm text-gray-600">
+                  {new Date(q.timestamp).toLocaleTimeString()}
+                </p>
+              </li>
+            ))}
+          </ul>
+        </div>
+      )}
+    </div>
+  );
+};

+ 920 - 0
client/mobile/components/Classroom/useClassroom.ts

@@ -0,0 +1,920 @@
+import { useState, useEffect, useRef } from 'react';
+import { useParams } from 'react-router';
+// @ts-types="../../../share/aliyun-rtc-sdk.d.ts"
+import AliRtcEngine, { AliRtcSubscribeState, AliRtcVideoTrack } from 'aliyun-rtc-sdk';
+import { toast } from 'react-toastify';
+
+export enum Role {
+  Teacher = 'admin',
+  Student = 'student'
+}
+
+// 从SDK中提取需要的类型和枚举
+type ImEngine = InstanceType<typeof AliVCInteraction.ImEngine>;
+type ImGroupManager = AliVCInteraction.AliVCIMGroupManager;
+type ImMessageManager = AliVCInteraction.AliVCIMMessageManager;
+type ImLogLevel = AliVCInteraction.ImLogLevel;
+type ImMessageLevel = AliVCInteraction.ImMessageLevel;
+const { ERROR } = AliVCInteraction.ImLogLevel;
+const { NORMAL, HIGH } = AliVCInteraction.ImMessageLevel;
+
+interface ImUser {
+  userId: string;
+  userExtension?: string;
+}
+
+interface ImGroupMessage {
+  groupId: string;
+  type: number;
+  data: string;
+  sender?: ImUser;
+  timestamp?: number;
+}
+
+// 互动消息类型
+enum InteractionAction {
+  HandUp = 'hand_up',
+  CancelHandUp = 'cancel_hand_up',
+  AnswerHandUp = 'answer_hand_up'
+}
+
+interface InteractionMessage {
+  action: InteractionAction;
+  studentId: string;
+  studentName?: string;
+  timestamp?: number;
+  question?: string;
+}
+
+interface HandUpRequest {
+  studentId: string;
+  studentName?: string;
+  timestamp: number;
+  question?: string;
+}
+
+interface Question {
+  studentId: string;
+  studentName?: string;
+  question: string;
+  timestamp: number;
+}
+
+export enum ClassStatus {
+  NOT_STARTED = 'not_started',
+  IN_PROGRESS = 'in_progress',
+  ENDED = 'ended'
+}
+
+// 配置信息
+const IM_APP_ID = '4c2ab5e1b1b0';
+const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
+const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
+const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
+const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
+
+export const useClassroom = () => {
+  // 状态管理
+  const [userId, setUserId] = useState<string>('');
+  const [isCameraOn, setIsCameraOn] = useState<boolean>(false);
+  const [isAudioOn, setIsAudioOn] = useState<boolean>(false);
+  const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false);
+  const [className, setClassName] = useState<string>('');
+  const [role, setRole] = useState<Role | undefined>();
+  const [classId, setClassId] = useState<string>('');
+  const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
+  const [isJoinedClass, setIsJoinedClass] = useState<boolean>(false);
+  const [msgText, setMsgText] = useState<string>('');
+  const [messageList, setMessageList] = useState<string[]>([]);
+  const [errorMessage, setErrorMessage] = useState<string>('');
+  const [classStatus, setClassStatus] = useState<ClassStatus>(ClassStatus.NOT_STARTED);
+  const [handUpList, setHandUpList] = useState<HandUpRequest[]>([]);
+  const [questions, setQuestions] = useState<Question[]>([]);
+  const [students, setStudents] = useState<Array<{id: string, name: string}>>([]);
+  const [shareLink, setShareLink] = useState<string>('');
+
+  // SDK实例
+  const imEngine = useRef<ImEngine | null>(null);
+  const imGroupManager = useRef<ImGroupManager | null>(null);
+  const imMessageManager = useRef<ImMessageManager | null>(null);
+  const aliRtcEngine = useRef<AliRtcEngine | null>(null);
+  const remoteVideoElMap = useRef<Record<string, HTMLVideoElement>>({});
+  const remoteVideoContainer = useRef<HTMLDivElement>(null);
+
+  // 辅助函数
+  const showMessage = (text: string): void => {
+    setMessageList((prevMessageList) => [...prevMessageList, text])
+  };
+
+  const showToast = (type: 'info' | 'success' | 'error', message: string): void => {
+    toast[type](message);
+  };
+
+  const hex = (buffer: ArrayBuffer): string => {
+    const hexCodes = [];
+    const view = new DataView(buffer);
+    for (let i = 0; i < view.byteLength; i += 4) {
+      const value = view.getUint32(i);
+      const stringValue = value.toString(16);
+      const padding = '00000000';
+      const paddedValue = (padding + stringValue).slice(-padding.length);
+      hexCodes.push(paddedValue);
+    }
+    return hexCodes.join('');
+  };
+
+  const generateToken = async (
+    appId: string,
+    appKey: string,
+    channelId: string,
+    userId: string,
+    timestamp: number
+  ): Promise<string> => {
+    const encoder = new TextEncoder();
+    const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);
+    const hash = await crypto.subtle.digest('SHA-256', data);
+    return hex(hash);
+  };
+
+  const generateImToken = async (userId: string, role: string): Promise<string> => {
+    const nonce = 'AK_4';
+    const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
+    const pendingShaStr = `${IM_APP_ID}${IM_APP_KEY}${userId}${nonce}${timestamp}${role}`;
+    const encoder = new TextEncoder();
+    const data = encoder.encode(pendingShaStr);
+    const hash = await crypto.subtle.digest('SHA-256', data);
+    return hex(hash);
+  };
+
+  // 事件监听函数
+  const listenImEvents = (): void => {
+    if (!imEngine.current) return;    
+    if (!role) return;    
+
+    imEngine.current.on('connectsuccess', () => {
+      showMessage('IM连接成功');
+    });
+
+    imEngine.current.on('disconnect', async (code: number) => {
+      showMessage(`IM断开连接: ${code}`);
+      // 自动重连
+      try {
+        const imToken = await generateImToken(userId, role);
+        await imEngine.current!.login({
+          user: {
+            userId,
+            userExtension: '{}'
+          },
+          userAuth: {
+            nonce: 'AK_4',
+            timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
+            token: imToken,
+            role
+          }
+        });
+        showMessage('IM自动重连成功');
+      } catch (err: unknown) {
+        const error = err as Error;
+        showMessage(`IM自动重连失败: ${error.message}`);
+      }
+    });
+  };
+
+  const listenGroupEvents = (): void => {
+    if (!imGroupManager.current) return;
+
+    imGroupManager.current.on('memberchange', (groupId: string, memberCount: number, joinUsers: ImUser[], leaveUsers: ImUser[]) => {
+      showMessage(`成员变更: 加入${joinUsers.length}人, 离开${leaveUsers.length}人`);
+    });
+  };
+
+  const listenMessageEvents = (): void => {
+    if (!imMessageManager.current) return;
+
+    imMessageManager.current.on('recvgroupmessage', (msg: AliVCInteraction.ImMessage, groupId: string) => {
+      if (msg.type === 88889) { // 课堂状态消息
+        try {
+          const data = JSON.parse(msg.data);
+          if (data.action === 'start_class') {
+            setClassStatus(ClassStatus.IN_PROGRESS);
+            showMessage('老师已开始上课');
+          } else if (data.action === 'end_class') {
+            setClassStatus(ClassStatus.ENDED);
+            showMessage('老师已结束上课');
+          }
+        } catch (err) {
+          console.error('解析课堂状态消息失败', err);
+        }
+      } else if (msg.type === 88890) { // 静音指令
+        try {
+          const data = JSON.parse(msg.data);
+          if (data.action === 'toggle_mute' && data.userId === userId) {
+            showMessage(data.mute ? '你已被老师静音' : '老师已取消你的静音');
+          }
+        } catch (err) {
+          console.error('解析静音指令失败', err);
+        }
+      } else if (msg.type === 88891) { // 举手消息
+        try {
+          const data = JSON.parse(msg.data) as InteractionMessage;
+          if (data.action === InteractionAction.HandUp) {
+            const handUpData: HandUpRequest = {
+              ...data,
+              timestamp: data.timestamp || Date.now()
+            };
+            setHandUpList([...handUpList, handUpData]);
+            showMessage(`${data.studentName || data.studentId} 举手了`);
+          } else if (data.action === InteractionAction.CancelHandUp) {
+            setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
+          }
+        } catch (err) {
+          console.error('解析举手消息失败', err);
+        }
+      } else if (msg.type === 88892) { // 问题消息
+        try {
+          const data = JSON.parse(msg.data) as {question: string};
+          if (typeof data.question === 'string') {
+            const question: Question = {
+              studentId: msg.sender?.userId || 'unknown',
+              studentName: (() => {
+                try {
+                  return msg.sender?.userExtension ? JSON.parse(msg.sender.userExtension)?.nickname : null;
+                } catch {
+                  return null;
+                }
+              })() || msg.sender?.userId || '未知用户',
+              question: data.question,
+              timestamp: msg.timestamp || Date.now()
+            };
+            setQuestions([...questions, question]);
+          }
+          showMessage(`收到问题: ${data.question}`);
+        } catch (err) {
+          console.error('解析问题消息失败', err);
+        }
+      } else if (msg.type === 88893) { // 应答消息
+        try {
+          const data = JSON.parse(msg.data) as InteractionMessage;
+          if (data.action === InteractionAction.AnswerHandUp && data.studentId === userId) {
+            showMessage('老师已应答你的举手');
+            setHandUpList(handUpList.filter(h => h.studentId !== data.studentId));
+          }
+        } catch (err) {
+          console.error('解析应答消息失败', err);
+        }
+      } else if (msg.type === 88888) { // 普通文本消息
+        showMessage(`${msg.sender?.userId || '未知用户'}: ${msg.data}`);
+      }
+    });
+  };
+
+  // RTC相关函数
+  const removeRemoteVideo = (userId: string, type: 'camera' | 'screen' = 'camera') => {
+    const vid = `${type}_${userId}`;
+    const el = remoteVideoElMap.current[vid];
+    if (el) {
+      aliRtcEngine.current!.setRemoteViewConfig(null, userId, type === 'camera' ? AliRtcVideoTrack.AliRtcVideoTrackCamera : AliRtcVideoTrack.AliRtcVideoTrackScreen);
+      el.pause();
+      remoteVideoContainer.current?.removeChild(el);
+      delete remoteVideoElMap.current[vid];
+    }
+  };
+
+  const listenRtcEvents = () => {
+    if (!aliRtcEngine.current) return;
+
+    showMessage('注册rtc事件监听')
+
+    aliRtcEngine.current.on('remoteUserOnLineNotify', (userId: string) => {
+      showMessage(`用户 ${userId} 加入课堂`);
+      console.log('用户上线通知:', userId);
+    });
+
+    aliRtcEngine.current.on('remoteUserOffLineNotify', (userId: string) => {
+      showMessage(`用户 ${userId} 离开课堂`);
+      console.log('用户下线通知:', userId);
+      removeRemoteVideo(userId, 'camera');
+      removeRemoteVideo(userId, 'screen');
+    });
+
+    aliRtcEngine.current.on('videoSubscribeStateChanged', (
+      userId: string,
+      oldState: AliRtcSubscribeState,
+      newState: AliRtcSubscribeState,
+      interval: number,
+      channelId: string
+    ) => {
+      console.log(`视频订阅状态变化: 用户 ${userId}, 旧状态 ${oldState}, 新状态 ${newState}`);
+
+      switch(newState) {
+        case 3: // 订阅成功
+          try {
+            console.log('开始创建远程视频元素');
+            
+            if (remoteVideoElMap.current[`camera_${userId}`]) {
+              console.log(`用户 ${userId} 的视频元素已存在`);
+              return;
+            }
+            
+            const video = document.createElement('video');
+            video.autoplay = true;
+            video.playsInline = true;
+            video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
+            
+            if (!remoteVideoContainer.current) {
+              console.error('远程视频容器未找到');
+              return;
+            }
+            
+            remoteVideoContainer.current.style.display = 'block';
+            remoteVideoContainer.current.appendChild(video);
+            remoteVideoElMap.current[`camera_${userId}`] = video;
+            
+            aliRtcEngine.current!.setRemoteViewConfig(
+              video,
+              userId,
+              AliRtcVideoTrack.AliRtcVideoTrackCamera
+            );
+            
+            console.log(`已订阅用户 ${userId} 的视频流`);
+            showMessage(`已显示用户 ${userId} 的视频`);
+          } catch (err) {
+            console.error(`订阅用户 ${userId} 视频流失败:`, err);
+            showMessage(`订阅用户 ${userId} 视频流失败`);
+          }
+          break;
+          
+        case 1: // 取消订阅
+          console.log(`取消订阅用户 ${userId} 的视频流`);
+          removeRemoteVideo(userId, 'camera');
+          break;
+          
+        case 2: // 订阅中
+          console.log(`正在订阅用户 ${userId} 的视频流...`);
+          break;
+          
+        default:
+          console.warn(`未知订阅状态: ${newState}`);
+      }
+    });
+
+    aliRtcEngine.current.on('screenShareSubscribeStateChanged', (
+      userId: string,
+      oldState: AliRtcSubscribeState,
+      newState: AliRtcSubscribeState,
+      elapseSinceLastState: number,
+      channel: string
+    ) => {
+      console.log(`屏幕分享订阅状态变更:uid=${userId}, oldState=${oldState}, newState=${newState}`);
+
+      switch(newState) {
+        case 3: // 订阅成功
+          try {
+            console.log('开始创建屏幕分享视频元素');
+            
+            if (remoteVideoElMap.current[`screen_${userId}`]) {
+              console.log(`用户 ${userId} 的屏幕分享元素已存在`);
+              return;
+            }
+            
+            const video = document.createElement('video');
+            video.autoplay = true;
+            video.playsInline = true;
+            video.className = 'w-80 h-45 mr-2 mb-2 bg-black';
+            
+            if (!remoteVideoContainer.current) {
+              console.error('远程视频容器未找到');
+              return;
+            }
+            
+            remoteVideoContainer.current.appendChild(video);
+            remoteVideoElMap.current[`screen_${userId}`] = video;
+            
+            aliRtcEngine.current!.setRemoteViewConfig(
+              video,
+              userId,
+              AliRtcVideoTrack.AliRtcVideoTrackScreen
+            );
+            
+            console.log(`已订阅用户 ${userId} 的屏幕分享流`);
+            showMessage(`已显示用户 ${userId} 的屏幕分享`);
+          } catch (err) {
+            console.error(`订阅用户 ${userId} 屏幕分享流失败:`, err);
+            showMessage(`订阅用户 ${userId} 屏幕分享流失败`);
+          }
+          break;
+          
+        case 1: // 取消订阅
+          console.log(`取消订阅用户 ${userId} 的屏幕分享流`);
+          removeRemoteVideo(userId, 'screen');
+          break;
+          
+        case 2: // 订阅中
+          console.log(`正在订阅用户 ${userId} 的屏幕分享流...`);
+          break;
+          
+        default:
+          console.warn(`未知屏幕分享订阅状态: ${newState}`);
+      }
+    });
+  };
+
+  // 课堂操作方法
+  const login = async (userId: string): Promise<void> => {
+    if(!role) return;
+
+    try {
+      const { ImEngine: ImEngineClass } = window.AliVCInteraction;
+      imEngine.current = ImEngineClass.createEngine();
+      await imEngine.current.init({
+        deviceId: 'xxxx',
+        appId: IM_APP_ID,
+        appSign: IM_APP_SIGN,
+        logLevel: ERROR,
+      });
+
+      const imToken = await generateImToken(userId, role);
+      await imEngine.current.login({
+        user: {
+          userId,
+          userExtension: '{}'
+        },
+        userAuth: {
+          nonce: 'AK_4',
+          timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
+          token: imToken,
+          role
+        }
+      });
+      
+      aliRtcEngine.current = AliRtcEngine.getInstance();
+      AliRtcEngine.setLogLevel(0);
+      
+      listenImEvents();
+      listenRtcEvents();
+      
+      setIsLoggedIn(true);
+      setErrorMessage('');
+      showToast('success', '登录成功');
+    } catch (err: any) {
+      setErrorMessage(`登录失败: ${err.message}`);
+      showToast('error', '登录失败');
+    }
+  };
+
+  const joinClass = async (classId?: string): Promise<void> => {
+    if (!imEngine.current || !aliRtcEngine.current) return;
+    
+    // 优先使用URL参数中的classId
+    const { id: pathClassId } = useParams();
+    const finalClassId = (classId || pathClassId) as string;
+    
+    if (!finalClassId) {
+      setErrorMessage('课堂ID不能为空');
+      showToast('error', '请输入有效的课堂ID');
+      return;
+    }
+
+    try {
+      const gm = imEngine.current.getGroupManager();
+      const mm = imEngine.current.getMessageManager();
+      imGroupManager.current = gm || null;
+      imMessageManager.current = mm || null;
+      await gm!.joinGroup(finalClassId);
+      listenGroupEvents();
+      listenMessageEvents();
+
+      await joinRtcChannel(finalClassId);
+
+      setIsJoinedClass(true);
+      setErrorMessage('');
+      showToast('success', '加入课堂成功');
+    } catch (err: any) {
+      setErrorMessage(`加入课堂失败: ${err.message}`);
+      showToast('error', '加入课堂失败');
+      
+      if (imGroupManager.current) {
+        try {
+          await imGroupManager.current.leaveGroup(finalClassId);
+        } catch (leaveErr) {
+          console.error('离开IM群组失败:', leaveErr);
+        }
+      }
+    }
+  };
+
+  const leaveClass = async (): Promise<void> => {
+    try {
+      if (imGroupManager.current && classId) {
+        await imGroupManager.current.leaveGroup(classId);
+      }
+      if (aliRtcEngine.current) {
+        await leaveRtcChannel();
+      }
+      
+      setIsJoinedClass(false);
+      showToast('info', '已离开课堂');
+    } catch (err) {
+      console.error('离开课堂失败:', err);
+      showToast('error', '离开课堂时发生错误');
+    }
+  };
+
+  const sendMessage = async (): Promise<void> => {
+    if (!imMessageManager.current || !classId) return;
+
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: msgText,
+        type: 88888,
+        level: NORMAL,
+      });
+      setMsgText('');
+      setErrorMessage('');
+    } catch (err: any) {
+      setErrorMessage(`消息发送失败: ${err.message}`);
+    }
+  };
+
+  const startClass = async (): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({ action: 'start_class' }),
+        type: 88889,
+        level: HIGH,
+      });
+      setClassStatus(ClassStatus.IN_PROGRESS);
+      showToast('success', '课堂已开始');
+    } catch (err: any) {
+      setErrorMessage(`开始上课失败: ${err.message}`);
+    }
+  };
+
+  const endClass = async (): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({ action: 'end_class' }),
+        type: 88889,
+        level: HIGH,
+      });
+      setClassStatus(ClassStatus.ENDED);
+      showToast('success', '课堂已结束');
+      
+      try {
+        await leaveRtcChannel();
+      } catch (err: any) {
+        console.error('离开RTC频道失败:', err);
+        showToast('error', '离开RTC频道失败');
+      }
+    } catch (err: any) {
+      setErrorMessage(`结束上课失败: ${err.message}`);
+    }
+  };
+
+  const toggleMuteMember = async (userId: string, mute: boolean): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: 'toggle_mute',
+          userId,
+          mute
+        }),
+        type: 88890,
+        level: HIGH,
+      });
+      showToast('info', mute ? `已静音用户 ${userId}` : `已取消静音用户 ${userId}`);
+    } catch (err: any) {
+      setErrorMessage(`操作失败: ${err.message}`);
+    }
+  };
+
+  const createClass = async (className: string, maxMembers = 200): Promise<string | null> => {
+    if (!imEngine.current || !isLoggedIn || role !== Role.Teacher) {
+      showToast('error', '只有老师可以创建课堂');
+      return null;
+    }
+    
+    try {
+      const groupManager = imEngine.current.getGroupManager();
+      if (!groupManager) {
+        throw new Error('群组管理器未初始化');
+      }
+
+      showToast('info', '正在创建课堂...');
+      
+      const response = await groupManager.createGroup({
+        groupName: className,
+        groupMeta: JSON.stringify({
+          classType: 'interactive',
+          creator: userId,
+          createdAt: Date.now(),
+          maxMembers
+        })
+      });
+
+      if (!response?.groupId) {
+        throw new Error('创建群组失败: 未返回群组ID');
+      }
+
+      try {
+        await groupManager.joinGroup(response.groupId);
+        
+        showToast('success', '课堂创建并加入成功');
+        showMessage(`课堂 ${className} 创建成功,ID: ${response.groupId}`);
+        
+        setClassId(response.groupId);
+        setIsJoinedClass(true);
+        
+        const messageManager = imEngine.current.getMessageManager();
+        if (messageManager) {
+          imMessageManager.current = messageManager;
+          listenMessageEvents();
+        }
+        
+        await joinRtcChannel(response.groupId);
+        
+        setShareLink(`${window.location.href.split('?')[0]}?classId=${response.groupId}`);
+        
+        return response.groupId;
+      } catch (joinErr: any) {
+        throw new Error(`创建成功但加入失败: ${joinErr.message}`);
+      }
+    } catch (err: any) {
+      const errorMsg = err.message.includes('alreadyExist')
+        ? '课堂已存在'
+        : `课堂创建失败: ${err.message}`;
+      
+      setErrorMessage(errorMsg);
+      showToast('error', errorMsg);
+      return null;
+    }
+  };
+
+  const joinRtcChannel = async (classId: string, publishOptions?: {
+    publishVideo?: boolean
+    publishAudio?: boolean
+    publishScreen?: boolean
+  }) => {
+    if (!aliRtcEngine.current) return;
+    const {
+      publishVideo = false,
+      publishAudio = false,
+      publishScreen = false,
+    } = publishOptions || {};
+    
+    const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
+    const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
+    await aliRtcEngine.current.publishLocalVideoStream(publishVideo);
+    await aliRtcEngine.current.publishLocalAudioStream(publishAudio);
+    await aliRtcEngine.current.publishLocalScreenShareStream(publishScreen);
+    await aliRtcEngine.current.joinChannel(
+      {
+        channelId: classId,
+        userId,
+        appId: RTC_APP_ID,
+        token,
+        timestamp,
+      },
+      userId
+    );
+  };
+
+  const leaveRtcChannel = async () => {
+    if (!aliRtcEngine.current) return;
+    await aliRtcEngine.current.leaveChannel();
+  };
+
+  // 切换摄像头状态
+  const toggleCamera = async () => {
+    if(!aliRtcEngine.current?.isInCall){
+      showToast('error', '先加入课堂');
+      return;
+    }
+
+    try {
+      if (isCameraOn) {
+        await aliRtcEngine.current?.stopPreview();
+        await aliRtcEngine.current?.enableLocalVideo(false)
+        await aliRtcEngine.current?.publishLocalVideoStream(false)
+      } else {
+        await aliRtcEngine.current?.setLocalViewConfig('localPreviewer', AliRtcVideoTrack.AliRtcVideoTrackCamera);
+        await aliRtcEngine.current?.enableLocalVideo(true)
+        await aliRtcEngine.current?.startPreview();
+        await aliRtcEngine.current?.publishLocalVideoStream(true)
+   
+      }
+      await aliRtcEngine.current?.startAndPublishDefaultDevices()
+      setIsCameraOn(!isCameraOn);
+    } catch (err) {
+      console.error('切换摄像头状态失败:', err);
+      showToast('error', '切换摄像头失败');
+    }
+  };
+
+  // 切换音频状态
+  const toggleAudio = async () => {
+    if(!aliRtcEngine.current?.isInCall){
+      showToast('error', '先加入课堂');
+      return;
+    }
+
+    try {
+      if (isAudioOn) {
+        await aliRtcEngine.current?.stopAudioCapture()
+        await aliRtcEngine.current?.publishLocalAudioStream(false);
+      } else {
+        await aliRtcEngine.current?.publishLocalAudioStream(true);
+      }
+      await aliRtcEngine.current?.startAndPublishDefaultDevices();
+      setIsAudioOn(!isAudioOn);
+    } catch (err) {
+      console.error('切换麦克风状态失败:', err);
+      showToast('error', '切换麦克风失败');
+    }
+  };
+
+  // 切换屏幕分享状态
+  const toggleScreenShare = async () => {
+    if(!aliRtcEngine.current?.isInCall){
+      showToast('error', '先加入课堂');
+      return;
+    }
+
+    try {
+      if (isScreenSharing) {
+        await aliRtcEngine.current?.publishLocalScreenShareStream(false)
+        await aliRtcEngine.current?.stopScreenShare()
+      } else {
+        await aliRtcEngine.current?.publishLocalScreenShareStream(true)
+        await aliRtcEngine.current?.setLocalViewConfig(
+          'screenPreviewer',
+          AliRtcVideoTrack.AliRtcVideoTrackScreen
+        );
+      }
+      await aliRtcEngine.current?.startAndPublishDefaultDevices()
+      setIsScreenSharing(!isScreenSharing);
+    } catch (err) {
+      console.error('切换屏幕分享失败:', err);
+      showToast('error', '切换屏幕分享失败');
+    }
+  };
+
+  const handUp = async (question?: string): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== 'student') return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: 'hand_up',
+          studentId: userId,
+          timestamp: Date.now(),
+          question
+        }),
+        type: 88891,
+        level: NORMAL,
+      });
+    } catch (err: any) {
+      setErrorMessage(`举手失败: ${err.message}`);
+    }
+  };
+
+  const muteStudent = async (studentId: string): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: 'toggle_mute',
+          userId: studentId,
+          mute: true
+        }),
+        type: 88890,
+        level: HIGH,
+      });
+      showToast('info', `已静音学生 ${studentId}`);
+    } catch (err: any) {
+      setErrorMessage(`静音失败: ${err.message}`);
+    }
+  };
+
+  const kickStudent = async (studentId: string): Promise<void> => {
+    if (!imGroupManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imGroupManager.current.leaveGroup(classId);
+      showToast('info', `已移出学生 ${studentId}`);
+    } catch (err: any) {
+      setErrorMessage(`移出失败: ${err.message}`);
+    }
+  };
+
+  const answerHandUp = async (studentId: string): Promise<void> => {
+    if (!imMessageManager.current || !classId || role !== Role.Teacher) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: JSON.stringify({
+          action: 'answer_hand_up',
+          studentId
+        }),
+        type: 88893,
+        level: HIGH,
+      });
+      showToast('info', `已应答学生 ${studentId} 的举手`);
+    } catch (err: any) {
+      setErrorMessage(`应答失败: ${err.message}`);
+    }
+  };
+
+  const sendQuestion = async (question: string): Promise<void> => {
+    if (!imMessageManager.current || !classId) return;
+    
+    try {
+      await imMessageManager.current.sendGroupMessage({
+        groupId: classId,
+        data: question,
+        type: 88892,
+        level: NORMAL,
+      });
+    } catch (err: any) {
+      setErrorMessage(`问题发送失败: ${err.message}`);
+    }
+  };
+
+  // 清理资源
+  useEffect(() => {
+    return () => {
+      if (imGroupManager.current) {
+        imGroupManager.current.removeAllListeners();
+      }
+      if (imMessageManager.current) {
+        imMessageManager.current.removeAllListeners();
+      }
+      if (imEngine.current) {
+        imEngine.current.removeAllListeners();
+      }
+      if (aliRtcEngine.current) {
+        aliRtcEngine.current.destroy();
+      }
+    };
+  }, []);
+
+  return {
+    // 状态
+    userId,
+    setUserId,
+    isCameraOn,
+    isAudioOn,
+    isScreenSharing,
+    className,
+    setClassName,
+    role,
+    setRole,
+    classId,
+    setClassId,
+    isLoggedIn,
+    isJoinedClass,
+    msgText,
+    setMsgText,
+    messageList,
+    errorMessage,
+    classStatus,
+    handUpList,
+    questions,
+    students,
+    shareLink,
+    remoteVideoContainer,
+
+    // 方法
+    login,
+    joinClass,
+    leaveClass,
+    sendMessage,
+    startClass,
+    endClass,
+    toggleMuteMember,
+    createClass,
+    toggleCamera,
+    toggleAudio,
+    toggleScreenShare,
+    handUp,
+    answerHandUp,
+    sendQuestion,
+    muteStudent,
+    kickStudent
+  };
+};
+

+ 2 - 2
client/mobile/mobile_app.tsx

@@ -21,7 +21,7 @@ import { ExclamationTriangleIcon, HomeIcon, BellIcon, UserIcon } from '@heroicon
 import { NotificationsPage } from './pages_messages.tsx';
 import { LivePage } from './pages_live.tsx';
 import { RTCPage } from './pages_rtc.tsx';
-import { ClassroomPage } from './pages_classroom.tsx'
+import { ClassroomPage } from './pages_classroom_new.tsx'
 
 // 设置中文语言
 dayjs.locale('zh-cn');
@@ -254,7 +254,7 @@ const App = () => {
       errorElement: <ErrorPage />
     },
     {
-      path: '/mobile/classroom',
+      path: '/mobile/classroom/:id',
       element: <ClassroomPage />,
       errorElement: <ErrorPage />
     },

+ 206 - 0
client/mobile/pages_classroom_new.tsx

@@ -0,0 +1,206 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router';
+import { Role, ClassStatus } from './components/Classroom/useClassroom.ts';
+import { TeacherView } from './components/Classroom/TeacherView.tsx';
+import { StudentView } from './components/Classroom/StudentView.tsx';
+import { ClassroomLayout } from './components/Classroom/ClassroomLayout.tsx';
+import { AuthLayout } from './components/Classroom/AuthLayout.tsx';
+import { ClassroomProvider, useClassroomContext } from "./components/Classroom/ClassroomProvider.tsx";
+import { ToastContainer } from 'react-toastify';
+
+const RoleSelection = () => {
+  const { setRole } = useClassroomContext();
+  
+  return (
+    <div className="flex flex-col items-center justify-center h-full">
+      <h2 className="text-2xl font-bold mb-8">请选择您的角色</h2>
+      <div className="flex space-x-4">
+        <button
+          onClick={() => setRole(Role.Teacher)}
+          className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
+        >
+          我是老师
+        </button>
+        <button
+          onClick={() => setRole(Role.Student)}
+          className="px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
+        >
+          我是学生
+        </button>
+      </div>
+    </div>
+  );
+};
+
+const JoinClassSection = () => {
+  const { joinClass } = useClassroomContext();
+  const [classId, setClassId] = useState('');
+
+  const handleJoinClass = async () => {
+    if (!classId.trim()) return;
+    await joinClass(classId);
+  };
+
+  return (
+    <div className="bg-white p-4 rounded shadow mb-4">
+      <h3 className="font-bold mb-2">加入课堂</h3>
+      <div className="flex space-x-2">
+        <input
+          type="text"
+          value={classId}
+          onChange={(e) => setClassId(e.target.value)}
+          placeholder="输入课堂ID"
+          className="flex-1 px-3 py-2 border rounded"
+        />
+        <button
+          onClick={handleJoinClass}
+          className="px-4 py-2 bg-blue-500 text-white rounded"
+        >
+          加入课堂
+        </button>
+      </div>
+    </div>
+  );
+};
+
+const CreateClassSection = () => {
+  const { classStatus, createClass, className, setClassName } = useClassroomContext();
+  const navigate = useNavigate();
+  
+  const handleCreateClass = async () => {
+    if (!className.trim()) return;
+    const classId = await createClass(className);
+    if (classId) {
+      navigate(`/mobile/classroom/${classId}`, { replace: true });
+    }
+  };
+
+  if (classStatus !== ClassStatus.NOT_STARTED) return null;
+
+  return (
+    <div className="bg-white p-4 rounded shadow mb-4">
+      <h3 className="font-bold mb-2">创建新课堂</h3>
+      <div className="flex space-x-2">
+        <input
+          type="text"
+          value={className}
+          onChange={(e) => setClassName(e.target.value)}
+          placeholder="输入课堂名称"
+          className="flex-1 px-3 py-2 border rounded"
+        />
+        <button
+          onClick={handleCreateClass}
+          className="px-4 py-2 bg-green-500 text-white rounded"
+        >
+          创建课堂
+        </button>
+      </div>
+    </div>
+  );
+};
+
+const UserIdInput = () => {
+  const { login: onLogin, userId, setUserId } = useClassroomContext();
+  const handleLogin = async () => {
+    if (!userId.trim()) return;
+    await onLogin(userId);
+  };
+
+  return (
+    <div className="flex flex-col items-center justify-center h-full">
+      <h2 className="text-2xl font-bold mb-8">请输入您的用户ID</h2>
+      <div className="flex space-x-2 w-64">
+        <input
+          type="text"
+          value={userId}
+          onChange={(e) => setUserId(e.target.value)}
+          placeholder="用户ID"
+          className="flex-1 px-3 py-2 border rounded"
+        />
+        <button
+          onClick={handleLogin}
+          className="px-4 py-2 bg-blue-500 text-white rounded"
+        >
+          登录
+        </button>
+      </div>
+    </div>
+  );
+};
+
+const Classroom = () => {
+  const context = useClassroomContext();
+  const { role, classStatus, isLoggedIn } = context;
+
+  if (!role) {
+    return (
+      <AuthLayout>
+        <RoleSelection />
+      </AuthLayout>
+    );
+  }
+
+  if (!isLoggedIn) {
+    return (
+      <AuthLayout>
+        <UserIdInput />
+      </AuthLayout>
+    );
+  }
+
+  if (role === Role.Teacher && !context.isJoinedClass) {
+    return (
+      <>
+        <AuthLayout>
+          <CreateClassSection />
+        </AuthLayout>
+      </>
+    );
+  }
+
+  if (role === Role.Student && !context.isJoinedClass) {
+    return (
+      <>
+        <AuthLayout>
+          <JoinClassSection />
+        </AuthLayout>
+      </>
+    );
+  }
+
+  if (classStatus === ClassStatus.ENDED) {
+    return (
+      <div className="text-center py-8">
+        <h2 className="text-xl font-bold">课堂已结束</h2>
+        <p>感谢参与本次课堂</p>
+      </div>
+    );
+  }
+
+  return (
+    <ClassroomLayout role={role}>
+      {role === Role.Teacher ? <TeacherView /> : <StudentView />}
+    </ClassroomLayout>
+  );
+};
+
+export const ClassroomPage = () => {
+  return (
+    <>
+      <ClassroomProvider>
+        <Classroom />
+      </ClassroomProvider>
+      <ToastContainer
+        position="top-right"
+        autoClose={5000}
+        hideProgressBar={false}
+        newestOnTop={false}
+        closeOnClick
+        rtl={false}
+        pauseOnFocusLoss
+        draggable
+        pauseOnHover
+      />
+    </>
+  )
+}