3547 lines
123 KiB
Vue
3547 lines
123 KiB
Vue
<template>
|
||
<div v-if="showLogin">
|
||
<!-- 登录界面 -->
|
||
<Login @loginSuccess="handleLoginSuccess" />
|
||
</div>
|
||
<div v-else>
|
||
<!-- 音频元素 -->
|
||
<audio ref="localAudio" autoplay muted class="audio-element"></audio>
|
||
<div id="audio"></div>
|
||
<div class="livekit-container">
|
||
<div v-if="!status" class="meeting-container">
|
||
<!-- 视频容器 -->
|
||
<div class="video-layout" :class="{
|
||
'screen-sharing-active': hasActiveScreenShare || isWhiteboardActive || enlargedParticipant,
|
||
'enlarged-mode': enlargedParticipant
|
||
}">
|
||
<!-- 左侧共享屏幕/白板/放大视频区域 -->
|
||
<div class="screen-share-area" v-if="hasActiveScreenShare || isWhiteboardActive || enlargedParticipant">
|
||
<div class="screen-share-header">
|
||
<h3 v-if="enlargedParticipant">
|
||
<span v-if="enlargedParticipant.identity === hostUid">我的放大视频</span>
|
||
<span v-else>放大视图 - {{ enlargedParticipant.identity }}</span>
|
||
</h3>
|
||
<h3 v-else-if="isWhiteboardActive">共享白板</h3>
|
||
<h3 v-else>共享屏幕</h3>
|
||
<div class="sharing-user" v-if="!enlargedParticipant">
|
||
<span v-if="isWhiteboardActive || hasActiveScreenShare">
|
||
由 {{ screenSharingUser }} 共享
|
||
</span>
|
||
</div>
|
||
<!-- v-if="enlargedParticipant && enlargedParticipant.identity === hostUid" -->
|
||
<el-button
|
||
v-if="enlargedParticipant && enlargedParticipant.identity === hostUid"
|
||
@click="closeEnlargedView"
|
||
type="info"
|
||
size="small"
|
||
class="close-enlarge-btn"
|
||
>
|
||
<!-- 关闭放大 -->
|
||
<img src="@/assets/images/shrink.png" style='width:16px;height:15px' alt="" />
|
||
</el-button>
|
||
</div>
|
||
<div class="screen-share-video">
|
||
<!-- 放大视频模式 -->
|
||
<div v-if="enlargedParticipant" class="enlarged-video-container">
|
||
<div class="video-wrapper enlarged-video-wrapper">
|
||
<!-- 放大视频激光笔 Canvas -->
|
||
<canvas
|
||
v-if="enlargedParticipant"
|
||
ref="enlargedLaserPointerCanvas"
|
||
class="laser-pointer-canvas enlarged-laser-canvas"
|
||
@dblclick="handleEnlargedCanvasDoubleClick"
|
||
@mousedown="handleEnlargedCanvasMouseDown"
|
||
@mousemove="handleEnlargedCanvasMouseMove"
|
||
@mouseup="handleEnlargedCanvasMouseUp"
|
||
@mouseleave="handleEnlargedCanvasMouseLeave"
|
||
></canvas>
|
||
<video
|
||
v-if="enlargedParticipant.hasCameraTrack"
|
||
:ref="el => setEnlargedVideoRef(el)"
|
||
autoplay
|
||
playsinline
|
||
class="enlarged-video-element"
|
||
@loadedmetadata="handleEnlargedVideoLoaded">
|
||
</video>
|
||
<!-- 如果没有视频轨道,显示提示 -->
|
||
<div v-if="!enlargedParticipant.hasCameraTrack" class="video-placeholder">
|
||
<i class="el-icon-user"></i>
|
||
<span>暂无视频流</span>
|
||
</div>
|
||
<div class="video-overlay">
|
||
<span class="participant-name">
|
||
{{ enlargedParticipant.identity === hostUid ? '我' : enlargedParticipant.identity }}
|
||
</span>
|
||
<span class="audio-indicator" :class="{ 'muted': !enlargedParticipant.audioEnabled }">
|
||
<i :class="enlargedParticipant.audioEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i>
|
||
</span>
|
||
<!-- 激光笔状态指示 -->
|
||
<span v-if="isLaserPointerActive && enlargedParticipant" class="laser-pointer-indicator">
|
||
<i class="el-icon-aim"></i> 激光笔模式中
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 白板模式 -->
|
||
<div v-else-if="isWhiteboardActive" class="whiteboard-container">
|
||
<tabulaRase
|
||
ref="whiteboardRef"
|
||
:roomId="roomId"
|
||
:userId="hostUid"
|
||
class="whiteboard-component"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 屏幕共享模式 -->
|
||
<div v-else class="video-wrapper screen-share-wrapper">
|
||
<!-- 激光笔 Canvas -->
|
||
<canvas
|
||
ref="laserPointerCanvas"
|
||
class="laser-pointer-canvas"
|
||
@dblclick="handleCanvasDoubleClick"
|
||
@mousedown="handleCanvasMouseDown"
|
||
@mousemove="handleCanvasMouseMove"
|
||
@mouseup="handleCanvasMouseUp"
|
||
@mouseleave="handleCanvasMouseLeave"
|
||
></canvas>
|
||
<div class="video-tracks">
|
||
<!-- 屏幕共享视频 -->
|
||
<video
|
||
v-if="activeScreenShareTrack"
|
||
:ref="el => setScreenShareVideoRef(el)"
|
||
autoplay
|
||
playsinline
|
||
class="screen-share-element"
|
||
@loadedmetadata="handleScreenShareLoaded">
|
||
</video>
|
||
<!-- 如果没有屏幕共享,显示提示 -->
|
||
<div v-if="!activeScreenShareTrack" class="video-placeholder">
|
||
<i class="el-icon-monitor"></i>
|
||
<span>暂无屏幕共享</span>
|
||
</div>
|
||
</div>
|
||
<div class="video-overlay">
|
||
<span class="participant-name">{{ screenSharingUser }}</span>
|
||
<!-- 激光笔状态指示 -->
|
||
<span v-if="isLaserPointerActive" class="laser-pointer-indicator">
|
||
<i class="el-icon-aim"></i> 激光笔模式中
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧视频区域 -->
|
||
<div class="participants-area" :class="{
|
||
'with-screen-share': hasActiveScreenShare || isWhiteboardActive || enlargedParticipant
|
||
}">
|
||
<div class="participants-header">
|
||
<h3>会议名称:{{ roomName }}</h3>
|
||
<h3>参会者 ({{ participantCount }})</h3>
|
||
</div>
|
||
<div class="video-grid" :class="{
|
||
'grid-layout': !hasActiveScreenShare && !enlargedParticipant && participantCount > 1
|
||
}">
|
||
<!-- 本地视频 -->
|
||
<div class="participant-card local-participant" :class="{ 'enlarged': enlargedParticipant && enlargedParticipant.identity === hostUid }">
|
||
<div class="participant-header">
|
||
<h3>我的视频 ({{ hostUid }})</h3>
|
||
<div class="status-indicator">
|
||
<el-icon :class="{ 'audio-on': microphoneEnabled, 'audio-off': !microphoneEnabled }">
|
||
<Microphone v-if="microphoneEnabled" />
|
||
<Mute v-else />
|
||
</el-icon>
|
||
</div>
|
||
<div class="participant-actions" v-if="participantCount > 1 && shouldShowEnlargeButtons && !enlargedParticipant">
|
||
<el-button
|
||
@click="enlargeParticipant({ identity: hostUid, hasCameraTrack: cameraEnabled, audioEnabled: microphoneEnabled })"
|
||
type="link"
|
||
size="small"
|
||
class="enlarge-btn"
|
||
title="放大视图"
|
||
>
|
||
<img src="@/assets/images/amplify.png" style='width:18px;height:12px' alt="" />
|
||
<!-- <i class="el-icon-zoom-in"></i>
|
||
放大 -->
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="video-wrapper">
|
||
<video ref="localVideo" autoplay muted playsinline class="video-element"></video>
|
||
<div class="video-overlay">
|
||
<span class="participant-name">{{ hostUid }}</span>
|
||
<span class="audio-indicator" :class="{ 'muted': !microphoneEnabled }">
|
||
<i :class="microphoneEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i>
|
||
</span>
|
||
</div>
|
||
<!-- 视频占位符 -->
|
||
<div v-if="!cameraEnabled" class="video-placeholder">
|
||
<i class="el-icon-user"></i>
|
||
<span>摄像头已关闭</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 远程视频-->
|
||
<div class="participant-card"
|
||
v-for="participant in remoteParticipantsArray"
|
||
:key="participant.identity"
|
||
:class="{ 'enlarged': enlargedParticipant && enlargedParticipant.identity === participant.identity }"
|
||
>
|
||
<div class="participant-header">
|
||
<h3>{{ participant.identity }}</h3>
|
||
<div class="status-indicator">
|
||
<el-icon :class="{ 'audio-on': participant.audioEnabled, 'audio-off': !participant.audioEnabled }">
|
||
<Microphone v-if="participant.audioEnabled" />
|
||
<Mute v-else />
|
||
</el-icon>
|
||
</div>
|
||
<!-- <div class="participant-actions" v-if="participantCount > 1">
|
||
<el-button
|
||
@click="enlargeParticipant(participant)"
|
||
type="text"
|
||
size="small"
|
||
class="enlarge-btn"
|
||
title="放大视图"
|
||
>
|
||
<i class="el-icon-zoom-in"></i>
|
||
</el-button>
|
||
</div> -->
|
||
</div>
|
||
<div class="video-wrapper">
|
||
<div class="video-tracks">
|
||
<!-- 摄像头视频 - 使用 ref 绑定 -->
|
||
<video
|
||
v-if="participant.hasCameraTrack"
|
||
:ref="el => setParticipantVideoRef(el, participant.identity, 'camera')"
|
||
autoplay
|
||
playsinline
|
||
class="video-element"
|
||
@loadedmetadata="() => handleVideoLoaded(participant.identity, 'camera')">
|
||
</video>
|
||
<!-- 如果没有视频轨道,显示提示 -->
|
||
<div v-if="!participant.hasCameraTrack" class="video-placeholder">
|
||
<i class="el-icon-user"></i>
|
||
<span>摄像头已关闭</span>
|
||
</div>
|
||
</div>
|
||
<div class="video-overlay">
|
||
<span class="participant-name">{{ participant.identity }}</span>
|
||
<span class="audio-indicator" :class="{ 'muted': !participant.audioEnabled }">
|
||
<i :class="participant.audioEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<!-- 远程参与者音频元素 -->
|
||
<audio
|
||
:ref="el => setParticipantAudioRef(el, participant.identity)"
|
||
autoplay
|
||
class="participant-audio"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 固定在底部的控制按钮 -->
|
||
<div class="fixed-controls">
|
||
<div class="controls-container">
|
||
<div class="microphone-control-group">
|
||
<el-button @click="toggleCamera" :type="cameraEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
|
||
{{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }}
|
||
</el-button>
|
||
<!-- 摄像头选择下拉菜单 -->
|
||
<el-dropdown trigger="click" @command="handleCameraCommand" @visible-change="handleCameraVisibleChange" class="control-dropdown microphone-dropdown">
|
||
<el-button :type="cameraEnabled ? 'danger' : 'info'" class="control-btn dropdown-btn" size="large">
|
||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||
</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="device in cameraDevices"
|
||
:key="device.deviceId"
|
||
:command="device.deviceId"
|
||
:class="{ 'selected-device': selectedCameraId === device.deviceId }"
|
||
>
|
||
<i class="el-icon-video-camera"></i>
|
||
{{ device.label || `摄像头 ${cameraDevices.indexOf(device) + 1}` }}
|
||
<el-icon v-if="selectedCameraId === device.deviceId" class="check-icon"><check /></el-icon>
|
||
</el-dropdown-item>
|
||
<el-dropdown-item divided command="refresh">
|
||
<el-icon><refresh /></el-icon>
|
||
刷新设备列表
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
<div class="microphone-control-group">
|
||
<el-button @click="toggleMicrophone" :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn microphone-btn" size="large">
|
||
{{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }}
|
||
</el-button>
|
||
<!-- 麦克风选择下拉菜单 -->
|
||
<el-dropdown trigger="click" @command="handleMicrophoneCommand" @visible-change="handleMicrophoneVisibleChange" class="control-dropdown microphone-dropdown">
|
||
<el-button :type="microphoneEnabled ? 'danger' : 'info'" class="control-btn dropdown-btn" size="large">
|
||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||
</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="device in microphoneDevices"
|
||
:key="device.deviceId"
|
||
:command="device.deviceId"
|
||
:class="{ 'selected-device': selectedMicrophoneId === device.deviceId }"
|
||
>
|
||
<i class="el-icon-microphone"></i>
|
||
{{ device.label || `麦克风 ${microphoneDevices.indexOf(device) + 1}` }}
|
||
<el-icon v-if="selectedMicrophoneId === device.deviceId" class="check-icon"><check /></el-icon>
|
||
</el-dropdown-item>
|
||
<el-dropdown-item divided command="refresh">
|
||
<el-icon><refresh /></el-icon>
|
||
刷新设备列表
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
<el-button
|
||
@click="toggleScreenShare"
|
||
:type="isScreenSharing ? 'danger' : (isGlobalScreenSharing ? 'primary' : 'info')"
|
||
:disabled="(isGlobalScreenSharing && !isScreenSharing) || !canScreenShare"
|
||
class="control-btn"
|
||
size="large"
|
||
>
|
||
<span v-if="isScreenSharing">停止共享</span>
|
||
<span v-else-if="isGlobalScreenSharing">他人共享中</span>
|
||
<span v-else>共享屏幕</span>
|
||
</el-button>
|
||
<el-button
|
||
@click="toggleWhiteboard"
|
||
:type="isWhiteboardActive ? 'danger' : 'info'"
|
||
:disabled="cameraEnabled || !canWhiteboardShare"
|
||
class="control-btn"
|
||
size="large"
|
||
>
|
||
{{ isWhiteboardActive ? '退出白板' : '共享白板' }}
|
||
</el-button>
|
||
<el-button
|
||
@click="toggleLaserPointer"
|
||
:type="isLaserPointerActive ? 'danger' : 'info'"
|
||
:disabled="!canUseLaserPointer"
|
||
class="control-btn"
|
||
size="large"
|
||
>
|
||
{{ isLaserPointerActive ? '关闭激光笔' : '激光笔' }}
|
||
</el-button>
|
||
<el-button @click="fileUploadHandle" type="info" class="control-btn" size="large">
|
||
文件
|
||
</el-button>
|
||
<el-button @click="inviterJoinRoom" type="info" class="control-btn" size="large">
|
||
邀请人员
|
||
</el-button>
|
||
<el-button @click="leaveRoomHandle" type="info" class="control-btn" size="large">
|
||
离开会议
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 邀请人员组件 -->
|
||
<InviterJoinRoom ref="inviterJoinRoomRef" @confirmSelection="handleConfirmSelection" />
|
||
<!-- 上传文件 -->
|
||
<FileList ref="fileListRef" :roomId="roomId"/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { reactive, ref, onMounted, onUnmounted, nextTick, computed, watch } from "vue";
|
||
import { ElMessage ,ElMessageBox} from 'element-plus';
|
||
import { ArrowDown, Refresh, Check } from '@element-plus/icons-vue';
|
||
import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi,getRoomInfoApi} from "@/api/conferencingRoom.js"
|
||
import { Room, RoomEvent, ParticipantEvent, Track } from "livekit-client";
|
||
import { errorHandling ,handleDataReceived ,handleReconnected,getDeviceName,handleVideoLoaded,handleConnectionStateChanged,drawSmoothCurve,simpleDeepEqual,generateElementId} from './business/index.js'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useRoomStore } from '@/stores/modules/room.js'
|
||
import { useUserStore } from '@/stores/modules/user.js'
|
||
import tabulaRase from '@/views/custom/tabulaRase/index.vue'
|
||
import { mqttClient } from "@/utils/mqtt.js";
|
||
import { emitter } from "@/utils/bus.js";
|
||
import Login from "@/components/Login/index.vue";
|
||
|
||
import InviterJoinRoom from "@/views/conferencingRoom/components/InviterJoinRoom/index.vue"
|
||
import FileList from "@/views/conferencingRoom/components/fileUpload/fileList.vue"
|
||
|
||
const userStore = useUserStore()
|
||
const roomStore = useRoomStore()
|
||
const route = useRoute();
|
||
const router = useRouter()
|
||
// LiveKit 服务器配置
|
||
const wsURL = "wss://meeting.cnsdt.com:443";
|
||
const hostUid = ref('')
|
||
const status = ref(true);
|
||
const roomName = ref('')
|
||
const roomId = ref('')
|
||
// 音视频相关引用和数据
|
||
const localVideo = ref(null);
|
||
const localAudio = ref(null);
|
||
const cameraEnabled = ref(false);
|
||
const microphoneEnabled = ref(false);
|
||
const isScreenSharing = ref(false);
|
||
const isLocalSpeaking = ref(false);
|
||
// 设备列表
|
||
const cameraDevices = ref([]);
|
||
const microphoneDevices = ref([]);
|
||
const selectedCameraId = ref('');
|
||
const selectedMicrophoneId = ref('');
|
||
// 远程参与者管理
|
||
const remoteParticipants = ref(new Map());
|
||
const videoElementsMap = ref(new Map());
|
||
const audioElementsMap = ref(new Map());
|
||
// 屏幕共享相关
|
||
const screenSharingUser = ref('');
|
||
const activeScreenShareTrack = ref(null);
|
||
const screenShareVideo = ref(null);//屏幕共享视频元素
|
||
const globalScreenSharingUser = ref(''); // 当前正在共享屏幕的用户
|
||
const isGlobalScreenSharing = ref(false); // 是否有用户正在共享屏幕
|
||
//共享白板
|
||
const isWhiteboardActive = ref(false);
|
||
const whiteboardRef = ref(null);
|
||
const isGlobalWhiteboardSharing = ref(false); // 是否有用户正在共享白板
|
||
//当前房间信息
|
||
const roomInfo = ref('')
|
||
//邀请参会人员
|
||
const inviterJoinRoomRef = ref()
|
||
// 激光笔相关状态
|
||
const isLaserPointerActive = ref(false);
|
||
const laserPointerCanvas = ref(null);
|
||
const laserPointerContext = ref(null);
|
||
const laserPointerElements = ref(new Map()); // 改为 Map 结构,key 为时间戳
|
||
const laserPointerTimeouts = ref(new Map()); // 管理每个元素的定时器
|
||
const laserPointerTimeout = ref(null);
|
||
const canvasSizeObserver = ref(null);
|
||
const screenShareVideoElement = ref(null);
|
||
|
||
//文件上传
|
||
const fileListRef = ref()
|
||
//mqtt相关
|
||
const isMqttFileuploadSucc = ref(false)
|
||
const isMqttFilePreview = ref(false)
|
||
const isMqttFileConversionStatus = ref(false)
|
||
const isMqttEnlargeVideo = ref(false)
|
||
const showLogin = ref(false); // 是否显示登录页面
|
||
|
||
// 放大视图相关
|
||
const enlargedParticipant = ref(null); // 当前放大的参与者
|
||
const enlargedVideo = ref(null); // 放大视频元素引用
|
||
const enlargedLaserPointerCanvas = ref(null); // 放大视频激光笔 Canvas
|
||
const enlargedLaserPointerContext = ref(null); // 放大视频激光笔上下文
|
||
|
||
// 路径更新节流处理
|
||
let lastPublishTime = 0;
|
||
const publishThrottleTime = 100; // 100ms 节流
|
||
|
||
// 鼠标状态跟踪
|
||
const mouseState = reactive({
|
||
isDrawing: false,
|
||
startX: 0,
|
||
startY: 0
|
||
});
|
||
// 放大视频鼠标状态跟踪
|
||
const enlargedMouseState = reactive({
|
||
isDrawing: false,
|
||
startX: 0,
|
||
startY: 0
|
||
});
|
||
|
||
// 激光笔样式配置
|
||
const laserPointerConfig = reactive({
|
||
color: '#ff0000', // 红色激光
|
||
thickness: 3,
|
||
duration: 2000, // 2秒后消失
|
||
fadeDuration: 300 // 淡出动画时间
|
||
});
|
||
|
||
// 白板消息类型
|
||
const WHITEBOARD_MESSAGE_TYPES = {
|
||
OPEN: 'open_whiteboard',
|
||
CLOSE: 'close_whiteboard',
|
||
SYNC: 'sync_whiteboard'
|
||
};
|
||
// 在 script 中添加激光笔消息类型和同步功能
|
||
const LASER_POINTER_MESSAGE_TYPES = {
|
||
DRAW: 'laser_draw',
|
||
CLEAR: 'laser_clear'
|
||
};
|
||
//视屏缩放类型
|
||
const VIDEO_ENLARGE_MESSAGE_TYPES = {
|
||
ENLARGE: 'enlarge_video',
|
||
SHRINK: 'shrink_video'
|
||
};
|
||
|
||
const participantCount = computed(() => {
|
||
return remoteParticipants.value.size + 1; // 包括自己
|
||
});
|
||
|
||
const remoteParticipantsArray = computed(() => {
|
||
return Array.from(remoteParticipants.value.values());
|
||
});
|
||
|
||
const hasActiveScreenShare = computed(() => {
|
||
return screenSharingUser.value !== '' || isScreenSharing.value;
|
||
});
|
||
|
||
// 添加激光笔可用性计算属性
|
||
const canUseLaserPointer = computed(() => {
|
||
return cameraEnabled.value || hasActiveScreenShare.value || enlargedParticipant.value;
|
||
});
|
||
// 是否显示放大按钮(当有屏幕共享时隐藏)
|
||
const shouldShowEnlargeButtons = computed(() => {
|
||
return !hasActiveScreenShare.value;
|
||
});
|
||
|
||
// 是否允许屏幕共享(当有放大视频时禁用)
|
||
const canScreenShare = computed(() => {
|
||
return !enlargedParticipant.value;
|
||
});
|
||
|
||
// 是否允许白板共享
|
||
const canWhiteboardShare = computed(() => {
|
||
return !enlargedParticipant.value;
|
||
});
|
||
|
||
// 添加一个计算属性来显示屏幕共享状态提示
|
||
const screenShareStatusText = computed(() => {
|
||
if (isGlobalScreenSharing.value) {
|
||
if (globalScreenSharingUser.value === hostUid.value) {
|
||
return '您正在共享屏幕';
|
||
} else {
|
||
return `${globalScreenSharingUser.value} 正在共享屏幕`;
|
||
}
|
||
}
|
||
return '暂无屏幕共享';
|
||
});
|
||
|
||
// 创建 Room 实例
|
||
const room = new Room({
|
||
adaptiveStream: true,
|
||
dynacast: true,
|
||
// 音频捕获配置
|
||
audioCaptureDefaults: {
|
||
echoCancellation: true,
|
||
noiseSuppression: true,
|
||
autoGainControl: true,
|
||
},
|
||
videoCaptureDefaults: {
|
||
resolution: { width: 1280, height: 720 },
|
||
},
|
||
publishDefaults: {
|
||
screenShareEncoding: {
|
||
maxBitrate: 3_000_000,
|
||
maxFramerate: 30,
|
||
},
|
||
videoEncoding: {
|
||
maxBitrate: 2_500_000,
|
||
maxFramerate: 30,
|
||
},
|
||
// 音频发布配置
|
||
audioEncoding: {
|
||
maxBitrate: 64000,
|
||
},
|
||
// dtx: true, // 不连续传输,节省带宽
|
||
red: true, // 冗余编码,提高抗丢包能力
|
||
}
|
||
});
|
||
|
||
emitter.on('whiteboardFailed',whiteboardFailedHandle);
|
||
|
||
/** 登录成功回调 */
|
||
function handleLoginSuccess() {
|
||
showLogin.value = false;
|
||
}
|
||
|
||
function whiteboardFailedHandle(e){
|
||
showLogin.value = e;
|
||
}
|
||
|
||
// 放大参与者视频
|
||
function enlargeParticipant(participant) {
|
||
// 只允许放大自己的视频
|
||
if (participant.identity !== hostUid.value) {
|
||
ElMessage.warning('只能放大自己的视频');
|
||
return;
|
||
}
|
||
|
||
if (participantCount.value <= 1) {
|
||
ElMessage.warning('需要至少2名参与者才能使用放大功能');
|
||
return;
|
||
}
|
||
|
||
if (!cameraEnabled.value) {
|
||
ElMessage.warning('请先打开摄像头');
|
||
return;
|
||
}
|
||
// 如果正在屏幕共享,自动停止
|
||
if (isScreenSharing.value) {
|
||
room.localParticipant.setScreenShareEnabled(false);
|
||
isScreenSharing.value = false;
|
||
ElMessage.info('已自动停止屏幕共享,开启视频放大模式');
|
||
}
|
||
// 如果正在使用白板,自动退出
|
||
if (isWhiteboardActive.value) {
|
||
exitWhiteboard();
|
||
ElMessage.info('已自动退出白板,开启视频放大模式');
|
||
}
|
||
|
||
// 如果正在放大其他用户,先关闭
|
||
if (enlargedParticipant.value && enlargedParticipant.value.identity !== hostUid.value) {
|
||
closeEnlargedView();
|
||
}
|
||
|
||
enlargedParticipant.value = participant;
|
||
ElMessage.success(`已放大您的视频`);
|
||
|
||
// 发布放大消息给其他用户
|
||
publishEnlargeVideoMessage(VIDEO_ENLARGE_MESSAGE_TYPES.ENLARGE, {
|
||
participant: {
|
||
identity: participant.identity,
|
||
hasCameraTrack: participant.hasCameraTrack,
|
||
audioEnabled: participant.audioEnabled
|
||
}
|
||
});
|
||
// 初始化放大视频激光笔
|
||
nextTick(() => {
|
||
initEnlargedLaserPointerCanvas();
|
||
});
|
||
}
|
||
|
||
// 关闭放大视图
|
||
function closeEnlargedView() {
|
||
// 检查激光笔是否开启,如果开启则先关闭激光笔
|
||
if (isLaserPointerActive.value) {
|
||
// 先关闭激光笔
|
||
isLaserPointerActive.value = false;
|
||
cleanupAllLaserElements();
|
||
|
||
// 更新 Canvas 样式
|
||
if (enlargedLaserPointerCanvas.value) {
|
||
enlargedLaserPointerCanvas.value.style.pointerEvents = 'none';
|
||
enlargedLaserPointerCanvas.value.style.cursor = 'default';
|
||
}
|
||
if (laserPointerCanvas.value) {
|
||
laserPointerCanvas.value.style.pointerEvents = 'none';
|
||
laserPointerCanvas.value.style.cursor = 'default';
|
||
}
|
||
}
|
||
// 如果当前放大的是自己的视频,发送缩小消息
|
||
if (enlargedParticipant.value && enlargedParticipant.value.identity === hostUid.value) {
|
||
publishEnlargeVideoMessage(VIDEO_ENLARGE_MESSAGE_TYPES.SHRINK, {
|
||
participant: {
|
||
identity: hostUid.value
|
||
}
|
||
});
|
||
}
|
||
enlargedParticipant.value = null;
|
||
ElMessage.info('已关闭放大视图');
|
||
// 清理放大视频激光笔
|
||
cleanupEnlargedLaserPointer();
|
||
}
|
||
// 设置放大视频引用
|
||
function setEnlargedVideoRef(el) {
|
||
if (!el) return;
|
||
enlargedVideo.value = el;
|
||
|
||
// 如果已经有轨道数据,立即附加
|
||
if (enlargedParticipant.value) {
|
||
if (enlargedParticipant.value.identity === hostUid.value) {
|
||
// 本地视频
|
||
if (localVideo.value && localVideo.value.srcObject) {
|
||
el.srcObject = localVideo.value.srcObject;
|
||
}
|
||
} else {
|
||
// 远程参与者
|
||
const participantData = remoteParticipants.value.get(enlargedParticipant.value.identity);
|
||
if (participantData && participantData.cameraTrack) {
|
||
attachTrackToVideo(el, participantData.cameraTrack);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理放大视频加载
|
||
function handleEnlargedVideoLoaded() {
|
||
// console.log('放大视频已加载');
|
||
// 初始化放大视频激光笔
|
||
initEnlargedLaserPointerCanvas();
|
||
}
|
||
|
||
// 初始化放大视频激光笔 Canvas
|
||
function initEnlargedLaserPointerCanvas() {
|
||
if (!enlargedLaserPointerCanvas.value || !enlargedParticipant.value) return;
|
||
|
||
const targetElement = document.querySelector('.enlarged-video-element');
|
||
if (!targetElement) return;
|
||
|
||
// 设置 Canvas 尺寸与目标元素一致
|
||
updateEnlargedCanvasSize(targetElement);
|
||
|
||
// 获取上下文
|
||
enlargedLaserPointerContext.value = enlargedLaserPointerCanvas.value.getContext('2d');
|
||
|
||
// 设置 Canvas 样式
|
||
enlargedLaserPointerCanvas.value.style.position = 'absolute';
|
||
enlargedLaserPointerCanvas.value.style.top = '0';
|
||
enlargedLaserPointerCanvas.value.style.left = '0';
|
||
enlargedLaserPointerCanvas.value.style.zIndex = '10';
|
||
enlargedLaserPointerCanvas.value.style.cursor = isLaserPointerActive.value ? 'crosshair' : 'default';
|
||
enlargedLaserPointerCanvas.value.style.pointerEvents = isLaserPointerActive.value ? 'auto' : 'none';
|
||
|
||
// 初始化观察器监听目标元素大小变化
|
||
initEnlargedResizeObserver(targetElement);
|
||
}
|
||
|
||
|
||
|
||
function updateEnlargedCanvasSize(targetElement) {
|
||
if (!enlargedLaserPointerCanvas.value || !targetElement) return;
|
||
|
||
const rect = targetElement.getBoundingClientRect();
|
||
enlargedLaserPointerCanvas.value.width = rect.width;
|
||
enlargedLaserPointerCanvas.value.height = rect.height;
|
||
enlargedLaserPointerCanvas.value.style.width = `${rect.width}px`;
|
||
enlargedLaserPointerCanvas.value.style.height = `${rect.height}px`;
|
||
|
||
// 重新绘制所有元素
|
||
redrawEnlargedLaserElements();
|
||
}
|
||
|
||
// 重绘放大视频激光笔元素
|
||
function redrawEnlargedLaserElements() {
|
||
if (!enlargedLaserPointerContext.value || !enlargedLaserPointerCanvas.value) return;
|
||
|
||
const ctx = enlargedLaserPointerContext.value;
|
||
ctx.clearRect(0, 0, enlargedLaserPointerCanvas.value.width, enlargedLaserPointerCanvas.value.height);
|
||
|
||
// 绘制所有活跃的元素
|
||
laserPointerElements.value.forEach(element => {
|
||
if (element.type === 'circle') {
|
||
const centerPixel = getEnlargedPixelFromPercentage(element.data.center);
|
||
drawEnlargedCircle(centerPixel);
|
||
} else if (element.type === 'line') {
|
||
const startPixel = getEnlargedPixelFromPercentage(element.data.start);
|
||
const endPixel = getEnlargedPixelFromPercentage(element.data.end);
|
||
drawEnlargedLine(startPixel.x, startPixel.y, endPixel.x, endPixel.y);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 放大视频绘制函数
|
||
function drawEnlargedCircle(centerPixel) {
|
||
if (!enlargedLaserPointerContext.value) return;
|
||
|
||
const ctx = enlargedLaserPointerContext.value;
|
||
ctx.beginPath();
|
||
ctx.arc(centerPixel.x, centerPixel.y, laserPointerConfig.thickness, 0, Math.PI * 2);
|
||
ctx.fillStyle = laserPointerConfig.color;
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawEnlargedLine(startX, startY, endX, endY) {
|
||
if (!enlargedLaserPointerContext.value) return;
|
||
|
||
const ctx = enlargedLaserPointerContext.value;
|
||
ctx.beginPath();
|
||
ctx.moveTo(startX, startY);
|
||
ctx.lineTo(endX, endY);
|
||
ctx.strokeStyle = laserPointerConfig.color;
|
||
ctx.lineWidth = laserPointerConfig.thickness;
|
||
ctx.lineCap = 'round';
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 初始化放大视频 ResizeObserver
|
||
function initEnlargedResizeObserver(targetElement) {
|
||
// 先清理之前的观察器
|
||
if (canvasSizeObserver.value) {
|
||
canvasSizeObserver.value.disconnect();
|
||
}
|
||
|
||
canvasSizeObserver.value = new ResizeObserver((entries) => {
|
||
for (let entry of entries) {
|
||
if (entry.target === targetElement) {
|
||
updateEnlargedCanvasSize(targetElement);
|
||
}
|
||
}
|
||
});
|
||
|
||
canvasSizeObserver.value.observe(targetElement);
|
||
}
|
||
|
||
// 清理放大视频激光笔
|
||
function cleanupEnlargedLaserPointer() {
|
||
if (enlargedLaserPointerContext.value && enlargedLaserPointerCanvas.value) {
|
||
enlargedLaserPointerContext.value.clearRect(0, 0, enlargedLaserPointerCanvas.value.width, enlargedLaserPointerCanvas.value.height);
|
||
}
|
||
enlargedMouseState.isDrawing = false;
|
||
}
|
||
|
||
// 放大视频 Canvas 事件处理
|
||
function handleEnlargedCanvasDoubleClick(e) {
|
||
if (!isLaserPointerActive.value || !enlargedParticipant.value) return;
|
||
|
||
const coords = getEnlargedMouseCoordinates(e);
|
||
|
||
// 创建圆形标记元素
|
||
const circleElement = {
|
||
type: 'circle',
|
||
data: {
|
||
color: laserPointerConfig.color,
|
||
center: coords,
|
||
thickness: laserPointerConfig.thickness
|
||
}
|
||
};
|
||
|
||
const elementId = addLaserElement(circleElement);
|
||
publishLaserPointerData({
|
||
...circleElement,
|
||
id: elementId,
|
||
target: 'enlarged' // 标记为放大视频激光笔
|
||
});
|
||
}
|
||
|
||
function handleEnlargedCanvasMouseDown(e) {
|
||
if (!isLaserPointerActive.value || !enlargedParticipant.value) return;
|
||
|
||
const pixelCoords = getEnlargedPixelCoordinates(e);
|
||
enlargedMouseState.isDrawing = true;
|
||
enlargedMouseState.startX = pixelCoords.x;
|
||
enlargedMouseState.startY = pixelCoords.y;
|
||
}
|
||
|
||
function handleEnlargedCanvasMouseMove(e) {
|
||
// 可以添加实时绘制预览
|
||
}
|
||
|
||
function handleEnlargedCanvasMouseUp(e) {
|
||
if (!isLaserPointerActive.value || !enlargedMouseState.isDrawing || !enlargedParticipant.value) return;
|
||
|
||
const endCoords = getEnlargedMouseCoordinates(e);
|
||
const startCoords = getEnlargedMouseCoordinates({
|
||
clientX: enlargedMouseState.startX + enlargedLaserPointerCanvas.value.getBoundingClientRect().left,
|
||
clientY: enlargedMouseState.startY + enlargedLaserPointerCanvas.value.getBoundingClientRect().top
|
||
});
|
||
|
||
// 创建线条元素
|
||
const lineElement = {
|
||
type: 'line',
|
||
data: {
|
||
color: laserPointerConfig.color,
|
||
start: startCoords,
|
||
end: endCoords,
|
||
thickness: laserPointerConfig.thickness
|
||
}
|
||
};
|
||
|
||
const startPixel = getEnlargedPixelFromPercentage(lineElement.data.start);
|
||
const endPixel = getEnlargedPixelFromPercentage(lineElement.data.end);
|
||
|
||
// 只有当起点和终点不同时才创建线条
|
||
if (!simpleDeepEqual(lineElement.data.start, lineElement.data.end)) {
|
||
const elementId = addLaserElement(lineElement);
|
||
publishLaserPointerData({
|
||
...lineElement,
|
||
id: elementId,
|
||
target: 'enlarged' // 标记为放大视频激光笔
|
||
});
|
||
}
|
||
|
||
enlargedMouseState.isDrawing = false;
|
||
}
|
||
|
||
function handleEnlargedCanvasMouseLeave() {
|
||
enlargedMouseState.isDrawing = false;
|
||
}
|
||
|
||
// 获取放大视频鼠标坐标(转换为百分比坐标)
|
||
function getEnlargedMouseCoordinates(e) {
|
||
if (!enlargedLaserPointerCanvas.value) return { x: 0, y: 0 };
|
||
|
||
const rect = enlargedLaserPointerCanvas.value.getBoundingClientRect();
|
||
return {
|
||
x: ((e.clientX - rect.left) / enlargedLaserPointerCanvas.value.width).toFixed(4),
|
||
y: ((e.clientY - rect.top) / enlargedLaserPointerCanvas.value.height).toFixed(4)
|
||
};
|
||
}
|
||
|
||
// 获取放大视频实际像素坐标
|
||
function getEnlargedPixelCoordinates(e) {
|
||
if (!enlargedLaserPointerCanvas.value) return { x: 0, y: 0 };
|
||
|
||
const rect = enlargedLaserPointerCanvas.value.getBoundingClientRect();
|
||
return {
|
||
x: e.clientX - rect.left,
|
||
y: e.clientY - rect.top
|
||
};
|
||
}
|
||
|
||
// 从百分比坐标转换为放大视频像素坐标
|
||
function getEnlargedPixelFromPercentage(percentageCoords) {
|
||
if (!enlargedLaserPointerCanvas.value) return { x: 0, y: 0 };
|
||
|
||
return {
|
||
x: parseFloat(percentageCoords.x) * enlargedLaserPointerCanvas.value.width,
|
||
y: parseFloat(percentageCoords.y) * enlargedLaserPointerCanvas.value.height
|
||
};
|
||
}
|
||
|
||
|
||
|
||
//订阅视屏缩放
|
||
async function initMqttEnlargeVideo(){
|
||
try {
|
||
if (isMqttEnlargeVideo.value) return
|
||
const clientId = `enlargeVideo_${Date.now()}`
|
||
await mqttClient.connect(clientId)
|
||
isMqttEnlargeVideo.value = true
|
||
// 订阅主题
|
||
subscribeToEnlargeVideoTopic()
|
||
} catch (error) {
|
||
console.error('MQTT连接失败:', error)
|
||
ElMessage.error('视屏缩放服务连接失败')
|
||
}
|
||
}
|
||
|
||
function subscribeToEnlargeVideoTopic() {
|
||
try {
|
||
mqttClient.subscribe(`xSynergy/enlarge_video/${room.name}`, handleEnlargeVideoMessage);
|
||
} catch (error) {
|
||
console.error('订阅激光笔主题失败:', error);
|
||
}
|
||
}
|
||
//接收视屏缩放消息
|
||
function handleEnlargeVideoMessage(payload, topic) {
|
||
try {
|
||
const messageStr = payload.toString();
|
||
const data = JSON.parse(messageStr);
|
||
// 只处理当前房间的消息
|
||
if (data.roomId !== room.name) return;
|
||
|
||
// 忽略自己发送的消息
|
||
if (data.sender === hostUid.value) return;
|
||
switch (data.type) {
|
||
case VIDEO_ENLARGE_MESSAGE_TYPES.ENLARGE:
|
||
handleRemoteVideoEnlarge(data);
|
||
break;
|
||
case VIDEO_ENLARGE_MESSAGE_TYPES.SHRINK:
|
||
handleRemoteVideoShrink(data);
|
||
break;
|
||
default:
|
||
console.warn('未知的视频放大消息类型:', data.type);
|
||
}
|
||
} catch (error) {
|
||
console.error('处理视频放大消息失败:', error);
|
||
}
|
||
}
|
||
|
||
// 处理远程视频放大
|
||
function handleRemoteVideoEnlarge(data) {
|
||
const { participant } = data.payload;
|
||
|
||
// 确保参与者存在
|
||
if (!remoteParticipants.value.has(participant.identity)) {
|
||
console.warn('收到放大消息,但参与者不存在:', participant.identity);
|
||
return;
|
||
}
|
||
// 如果当前正在屏幕共享,自动停止
|
||
if (isScreenSharing.value) {
|
||
room.localParticipant.setScreenShareEnabled(false);
|
||
isScreenSharing.value = false;
|
||
ElMessage.info('其他用户开启了视频放大,已自动停止屏幕共享');
|
||
}
|
||
|
||
// 如果当前正在使用白板,自动退出
|
||
if (isWhiteboardActive.value) {
|
||
exitWhiteboard();
|
||
ElMessage.info('其他用户开启了视频放大,已自动退出白板');
|
||
}
|
||
|
||
// 如果当前正在放大其他用户,先关闭
|
||
if (enlargedParticipant.value && enlargedParticipant.value.identity !== participant.identity) {
|
||
closeEnlargedView();
|
||
}
|
||
|
||
// 放大远程参与者的视频
|
||
enlargedParticipant.value = {
|
||
identity: participant.identity,
|
||
hasCameraTrack: participant.hasCameraTrack,
|
||
audioEnabled: participant.audioEnabled
|
||
};
|
||
|
||
ElMessage.info(`${participant.identity} 放大了自己的视频`);
|
||
|
||
// 确保视频轨道已附加
|
||
nextTick(() => {
|
||
const participantData = remoteParticipants.value.get(participant.identity);
|
||
if (participantData && participantData.cameraTrack && enlargedVideo.value) {
|
||
attachTrackToVideo(enlargedVideo.value, participantData.cameraTrack);
|
||
}
|
||
});
|
||
|
||
// 初始化放大视频激光笔
|
||
nextTick(() => {
|
||
initEnlargedLaserPointerCanvas();
|
||
});
|
||
}
|
||
|
||
// 处理远程视频缩小
|
||
function handleRemoteVideoShrink(data) {
|
||
const { participant } = data.payload;
|
||
|
||
// 如果当前放大的正是这个用户,关闭放大视图
|
||
if (enlargedParticipant.value && enlargedParticipant.value.identity === participant.identity) {
|
||
enlargedParticipant.value = null;
|
||
ElMessage.info(`${participant.identity} 关闭了放大视图`);
|
||
if(isLaserPointerActive.value){
|
||
isLaserPointerActive.value = false;
|
||
cleanupAllLaserElements();
|
||
}
|
||
// 清理放大视频激光笔
|
||
cleanupEnlargedLaserPointer();
|
||
}
|
||
}
|
||
|
||
//发送视屏缩放消息
|
||
function publishEnlargeVideoMessage(type, payload = {}) {
|
||
try {
|
||
const message = {
|
||
type: type,
|
||
roomId: room.name,
|
||
sender: hostUid.value,
|
||
timestamp: Date.now(),
|
||
payload: payload
|
||
};
|
||
mqttClient.publish(`xSynergy/enlarge_video/${room.name}`, message);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('发布视频放大消息失败:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 初始化MQTT连接 文件上传成功
|
||
async function initMqttFileUploadSucc(){
|
||
try {
|
||
if (isMqttFileuploadSucc.value) return
|
||
const clientId = `fileUpload_${Date.now()}`
|
||
await mqttClient.connect(clientId)
|
||
isMqttFileuploadSucc.value = true
|
||
// 订阅主题
|
||
emitter.emit('subscribeToFileUploadTopic',{roomId:roomId.value})
|
||
} catch (error) {
|
||
console.error('MQTT连接失败:', error)
|
||
ElMessage.error('文件上传服务连接失败')
|
||
}
|
||
}
|
||
|
||
//文件预览MQTT链接
|
||
async function initMqttFilePreview(){
|
||
try {
|
||
if (isMqttFilePreview.value) return
|
||
const clientId = `filePreview_${Date.now()}`
|
||
await mqttClient.connect(clientId)
|
||
isMqttFilePreview.value = true
|
||
// 订阅主题
|
||
emitter.emit('subscribeToFilePreviewTopic',{roomId:roomId.value})
|
||
} catch (error) {
|
||
console.error('MQTT连接失败:', error)
|
||
ElMessage.error('文件上传服务连接失败')
|
||
}
|
||
}
|
||
//订阅文件状态主题
|
||
async function initMqttFileConversionStatus(){
|
||
try {
|
||
if (isMqttFileConversionStatus.value) return
|
||
const clientId = `fileConversionStatus_${Date.now()}`
|
||
await mqttClient.connect(clientId)
|
||
isMqttFileConversionStatus.value = true
|
||
// 订阅主题
|
||
emitter.emit('subscribeToFileConversionStatusTopic',{roomId:roomId.value})
|
||
} catch (error) {
|
||
console.error('MQTT连接失败:', error)
|
||
ElMessage.error('文件上传服务连接失败')
|
||
}
|
||
}
|
||
|
||
// 激光笔功能
|
||
function toggleLaserPointer() {
|
||
if (!canUseLaserPointer.value) {
|
||
ElMessage.warning('请在开启摄像头或屏幕共享时使用激光笔');
|
||
return;
|
||
}
|
||
// && !isWhiteboardActive.value
|
||
if (!hasActiveScreenShare.value && !enlargedParticipant.value) {
|
||
ElMessage.warning('请在屏幕共享、放大视图模式下使用激光笔');
|
||
return;
|
||
}
|
||
|
||
isLaserPointerActive.value = !isLaserPointerActive.value;
|
||
if (isLaserPointerActive.value) {
|
||
// 根据当前模式初始化对应的激光笔 Canvas
|
||
if (enlargedParticipant.value) {
|
||
initEnlargedLaserPointerCanvas();
|
||
} else {
|
||
initLaserPointerCanvas();
|
||
}
|
||
ElMessage.success('激光笔已开启,双击添加标记,拖拽绘制线条');
|
||
} else {
|
||
cleanupAllLaserElements();
|
||
ElMessage.info('激光笔已关闭');
|
||
}
|
||
}
|
||
|
||
// 清理所有激光笔元素
|
||
function cleanupAllLaserElements() {
|
||
// 清除所有定时器
|
||
laserPointerTimeouts.value.forEach((timeoutId, elementId) => {
|
||
clearTimeout(timeoutId);
|
||
});
|
||
laserPointerTimeouts.value.clear();
|
||
|
||
// 清空所有元素
|
||
laserPointerElements.value.clear();
|
||
|
||
// 清除画布
|
||
if (laserPointerContext.value && laserPointerCanvas.value) {
|
||
laserPointerContext.value.clearRect(0, 0, laserPointerCanvas.value.width, laserPointerCanvas.value.height);
|
||
}
|
||
|
||
// 清除放大视频画布
|
||
if (enlargedLaserPointerContext.value && enlargedLaserPointerCanvas.value) {
|
||
enlargedLaserPointerContext.value.clearRect(0, 0, enlargedLaserPointerCanvas.value.width, enlargedLaserPointerCanvas.value.height);
|
||
}
|
||
|
||
mouseState.isDrawing = false;
|
||
enlargedMouseState.isDrawing = false;
|
||
}
|
||
|
||
// 添加新的激光笔元素
|
||
function addLaserElement(element) {
|
||
const elementId = generateElementId();
|
||
element.id = elementId;
|
||
element.timestamp = Date.now();
|
||
|
||
laserPointerElements.value.set(elementId, element);
|
||
|
||
// 为每个元素设置独立的定时器
|
||
const timeoutId = setTimeout(() => {
|
||
removeLaserElement(elementId);
|
||
}, laserPointerConfig.duration);
|
||
|
||
laserPointerTimeouts.value.set(elementId, timeoutId);
|
||
|
||
// 重新绘制所有元素
|
||
redrawLaserElements();
|
||
redrawEnlargedLaserElements();
|
||
|
||
return elementId;
|
||
}
|
||
|
||
// 移除单个激光笔元素
|
||
function removeLaserElement(elementId) {
|
||
// 清除定时器
|
||
if (laserPointerTimeouts.value.has(elementId)) {
|
||
clearTimeout(laserPointerTimeouts.value.get(elementId));
|
||
laserPointerTimeouts.value.delete(elementId);
|
||
}
|
||
|
||
// 移除元素
|
||
laserPointerElements.value.delete(elementId);
|
||
|
||
// 重新绘制
|
||
redrawLaserElements();
|
||
redrawEnlargedLaserElements();
|
||
}
|
||
|
||
function handleScreenShareLoaded() {
|
||
// 视频加载完成后初始化激光笔 Canvas
|
||
initLaserPointerCanvas();
|
||
}
|
||
|
||
// 初始化激光笔 Canvas
|
||
function initLaserPointerCanvas() {
|
||
if (!laserPointerCanvas.value) return;
|
||
|
||
let targetElement;
|
||
if (hasActiveScreenShare.value) {
|
||
targetElement = document.querySelector('.screen-share-element');
|
||
screenShareVideoElement.value = targetElement;
|
||
} else if (enlargedParticipant.value) {
|
||
targetElement = document.querySelector('.enlarged-video-element');
|
||
screenShareVideoElement.value = targetElement;
|
||
}
|
||
|
||
if (!targetElement) return;
|
||
|
||
// 设置 Canvas 尺寸与目标元素一致
|
||
updateCanvasSize(targetElement);
|
||
|
||
// 获取上下文
|
||
laserPointerContext.value = laserPointerCanvas.value.getContext('2d');
|
||
|
||
// 设置 Canvas 样式
|
||
laserPointerCanvas.value.style.position = 'absolute';
|
||
laserPointerCanvas.value.style.top = '0';
|
||
laserPointerCanvas.value.style.left = '0';
|
||
laserPointerCanvas.value.style.zIndex = '10';
|
||
laserPointerCanvas.value.style.cursor = isLaserPointerActive.value ? 'crosshair' : 'default';
|
||
laserPointerCanvas.value.style.pointerEvents = isLaserPointerActive.value ? 'auto' : 'none';
|
||
|
||
// 初始化观察器监听目标元素大小变化
|
||
initResizeObserver(targetElement);
|
||
}
|
||
|
||
function updateCanvasSize(targetElement) {
|
||
if (!laserPointerCanvas.value || !targetElement) return;
|
||
|
||
const rect = targetElement.getBoundingClientRect();
|
||
laserPointerCanvas.value.width = rect.width;
|
||
laserPointerCanvas.value.height = rect.height;
|
||
laserPointerCanvas.value.style.width = `${rect.width}px`;
|
||
laserPointerCanvas.value.style.height = `${rect.height}px`;
|
||
|
||
// 重新绘制所有元素
|
||
redrawLaserElements();
|
||
}
|
||
|
||
// 初始化 ResizeObserver 监听元素大小变化
|
||
function initResizeObserver(targetElement) {
|
||
// 先清理之前的观察器
|
||
if (canvasSizeObserver.value) {
|
||
canvasSizeObserver.value.disconnect();
|
||
}
|
||
|
||
canvasSizeObserver.value = new ResizeObserver((entries) => {
|
||
for (let entry of entries) {
|
||
if (entry.target === targetElement) {
|
||
updateCanvasSize(targetElement);
|
||
}
|
||
}
|
||
});
|
||
|
||
canvasSizeObserver.value.observe(targetElement);
|
||
}
|
||
|
||
// 清理激光笔
|
||
function cleanupLaserPointer() {
|
||
if (laserPointerTimeout.value) {
|
||
clearTimeout(laserPointerTimeout.value);
|
||
laserPointerTimeout.value = null;
|
||
}
|
||
|
||
if (laserPointerContext.value && laserPointerCanvas.value) {
|
||
laserPointerContext.value.clearRect(0, 0, laserPointerCanvas.value.width, laserPointerCanvas.value.height);
|
||
}
|
||
|
||
laserPointerElements.value = [];
|
||
mouseState.isDrawing = false;
|
||
}
|
||
|
||
// 获取鼠标坐标(转换为百分比坐标)
|
||
function getMouseCoordinates(e) {
|
||
if (!laserPointerCanvas.value) return { x: 0, y: 0 };
|
||
|
||
const rect = laserPointerCanvas.value.getBoundingClientRect();
|
||
return {
|
||
x: ((e.clientX - rect.left) / laserPointerCanvas.value.width).toFixed(4),
|
||
y: ((e.clientY - rect.top) / laserPointerCanvas.value.height).toFixed(4)
|
||
};
|
||
}
|
||
|
||
// 获取实际像素坐标
|
||
function getPixelCoordinates(e) {
|
||
if (!laserPointerCanvas.value) return { x: 0, y: 0 };
|
||
|
||
const rect = laserPointerCanvas.value.getBoundingClientRect();
|
||
return {
|
||
x: e.clientX - rect.left,
|
||
y: e.clientY - rect.top
|
||
};
|
||
}
|
||
|
||
// Canvas 事件处理 双击
|
||
function handleCanvasDoubleClick(e) {
|
||
if (!isLaserPointerActive.value) return;
|
||
const coords = getMouseCoordinates(e); // 使用归一化坐标
|
||
// 创建圆形标记元素 - 只存储归一化坐标
|
||
const circleElement = {
|
||
type: 'circle',
|
||
data: {
|
||
color: laserPointerConfig.color,
|
||
center: coords,
|
||
thickness: laserPointerConfig.thickness
|
||
}
|
||
};
|
||
|
||
const elementId = addLaserElement(circleElement);
|
||
publishLaserPointerData({
|
||
...circleElement,
|
||
id: elementId
|
||
});
|
||
}
|
||
|
||
function handleCanvasMouseDown(e) {
|
||
if (!isLaserPointerActive.value) return;
|
||
|
||
const pixelCoords = getPixelCoordinates(e);
|
||
mouseState.isDrawing = true;
|
||
mouseState.startX = pixelCoords.x;
|
||
mouseState.startY = pixelCoords.y;
|
||
}
|
||
|
||
function handleCanvasMouseMove(e) {
|
||
|
||
}
|
||
|
||
function handleCanvasMouseUp(e) {
|
||
if (!isLaserPointerActive.value || !mouseState.isDrawing) return;
|
||
|
||
const endCoords = getMouseCoordinates(e);
|
||
const startCoords = getMouseCoordinates({
|
||
clientX: mouseState.startX + laserPointerCanvas.value.getBoundingClientRect().left,
|
||
clientY: mouseState.startY + laserPointerCanvas.value.getBoundingClientRect().top
|
||
});
|
||
|
||
// 创建线条元素
|
||
const lineElement = {
|
||
type: 'line',
|
||
data: {
|
||
color: laserPointerConfig.color,
|
||
start: startCoords,
|
||
end: endCoords,
|
||
thickness: laserPointerConfig.thickness
|
||
}
|
||
};
|
||
|
||
const startPixel = getPixelFromPercentage(lineElement.data.start);
|
||
const endPixel = getPixelFromPercentage(lineElement.data.end);
|
||
|
||
// 只有当起点和终点不同时才创建线条
|
||
if (!simpleDeepEqual(lineElement.data.start, lineElement.data.end)) {
|
||
const elementId = addLaserElement(lineElement);
|
||
publishLaserPointerData({
|
||
...lineElement,
|
||
id: elementId
|
||
});
|
||
}
|
||
|
||
mouseState.isDrawing = false;
|
||
}
|
||
|
||
function handleCanvasMouseLeave() {
|
||
mouseState.isDrawing = false;
|
||
}
|
||
// 绘制函数
|
||
function drawCircle(centerPixel) {
|
||
if (!laserPointerContext.value) return;
|
||
const ctx = laserPointerContext.value;
|
||
ctx.beginPath();
|
||
ctx.arc(centerPixel.x, centerPixel.y, laserPointerConfig.thickness, 0, Math.PI * 2);
|
||
ctx.fillStyle = laserPointerConfig.color;
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawLine(startX, startY, endX, endY) {
|
||
if (!laserPointerContext.value) return;
|
||
|
||
const ctx = laserPointerContext.value;
|
||
ctx.beginPath();
|
||
ctx.moveTo(startX, startY);
|
||
ctx.lineTo(endX, endY);
|
||
ctx.strokeStyle = laserPointerConfig.color;
|
||
ctx.lineWidth = laserPointerConfig.thickness;
|
||
ctx.lineCap = 'round';
|
||
ctx.stroke();
|
||
}
|
||
// 重绘所有元素
|
||
function redrawLaserElements() {
|
||
if (!laserPointerContext.value || !laserPointerCanvas.value) return;
|
||
|
||
const ctx = laserPointerContext.value;
|
||
ctx.clearRect(0, 0, laserPointerCanvas.value.width, laserPointerCanvas.value.height);
|
||
|
||
// 绘制所有活跃的元素
|
||
laserPointerElements.value.forEach(element => {
|
||
if (element.type === 'circle') {
|
||
const centerPixel = getPixelFromPercentage(element.data.center);
|
||
drawCircle(centerPixel);
|
||
} else if (element.type === 'line') {
|
||
const startPixel = getPixelFromPercentage(element.data.start);
|
||
const endPixel = getPixelFromPercentage(element.data.end);
|
||
drawLine(startPixel.x, startPixel.y, endPixel.x, endPixel.y);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 从百分比坐标转换为像素坐标
|
||
function getPixelFromPercentage(percentageCoords) {
|
||
if (!laserPointerCanvas.value) return { x: 0, y: 0 };
|
||
|
||
return {
|
||
x: parseFloat(percentageCoords.x) * laserPointerCanvas.value.width,
|
||
y: parseFloat(percentageCoords.y) * laserPointerCanvas.value.height
|
||
};
|
||
}
|
||
|
||
// 安排清理
|
||
function scheduleCleanup() {
|
||
if (laserPointerTimeout.value) {
|
||
clearTimeout(laserPointerTimeout.value);
|
||
}
|
||
|
||
laserPointerTimeout.value = setTimeout(() => {
|
||
const now = Date.now();
|
||
laserPointerElements.value = laserPointerElements.value.filter(
|
||
element => now - element.data.timestamp < laserPointerConfig.duration
|
||
);
|
||
redrawLaserElements();
|
||
|
||
// 如果还有元素,继续清理
|
||
if (laserPointerElements.value.length > 0) {
|
||
scheduleCleanup();
|
||
}
|
||
}, laserPointerConfig.duration);
|
||
}
|
||
|
||
// 响应式调整 Canvas 大小
|
||
function resizeLaserPointerCanvas() {
|
||
if (!isLaserPointerActive.value || !laserPointerCanvas.value) return;
|
||
|
||
let targetElement;
|
||
if (hasActiveScreenShare.value) {
|
||
targetElement = document.querySelector('.screen-share-element');
|
||
} else if (enlargedParticipant.value) {
|
||
targetElement = document.querySelector('.enlarged-video-element');
|
||
}
|
||
|
||
if (targetElement) {
|
||
updateCanvasSize(targetElement);
|
||
}
|
||
}
|
||
|
||
// 响应式调整放大视频 Canvas 大小
|
||
function resizeEnlargedLaserPointerCanvas() {
|
||
if (!isLaserPointerActive.value || !enlargedLaserPointerCanvas.value || !enlargedParticipant.value) return;
|
||
|
||
const targetElement = document.querySelector('.enlarged-video-element');
|
||
if (targetElement) {
|
||
updateEnlargedCanvasSize(targetElement);
|
||
}
|
||
}
|
||
|
||
// 监听窗口大小变化
|
||
window.addEventListener('resize', () => {
|
||
resizeLaserPointerCanvas();
|
||
resizeEnlargedLaserPointerCanvas();
|
||
});
|
||
|
||
// 在初始化 MQTT 时订阅激光笔主题
|
||
function subscribeToLaserPointerTopic() {
|
||
try {
|
||
mqttClient.subscribe(`xSynergy/laserPointer/${room.name}`, handleLaserPointerMessage);
|
||
} catch (error) {
|
||
console.error('订阅激光笔主题失败:', error);
|
||
}
|
||
}
|
||
|
||
// 发布激光笔数据
|
||
function publishLaserPointerData(element) {
|
||
try {
|
||
const message = {
|
||
type: LASER_POINTER_MESSAGE_TYPES.DRAW,
|
||
roomId: roomId.value,
|
||
sender: hostUid.value,
|
||
timestamp: Date.now(),
|
||
element: element
|
||
};
|
||
|
||
mqttClient.publish(`xSynergy/laserPointer/${room.name}`, message);
|
||
} catch (error) {
|
||
console.error('发布激光笔数据失败:', error);
|
||
}
|
||
}
|
||
// 处理接收到的激光笔数据
|
||
function handleLaserPointerMessage(payload, topic) {
|
||
try {
|
||
const messageStr = payload.toString();
|
||
const data = JSON.parse(messageStr);
|
||
|
||
// 只处理当前房间的消息
|
||
if (data.roomId !== room.name) return;
|
||
|
||
// 忽略自己发送的消息
|
||
if (data.sender === hostUid.value) return;
|
||
|
||
switch (data.type) {
|
||
case LASER_POINTER_MESSAGE_TYPES.DRAW:
|
||
handleRemoteLaserDraw(data);
|
||
break;
|
||
|
||
case LASER_POINTER_MESSAGE_TYPES.CLEAR:
|
||
cleanupAllLaserElements();
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
console.error('处理激光笔消息失败:', error);
|
||
}
|
||
}
|
||
|
||
function handleRemoteLaserDraw(data) {
|
||
const { element } = data;
|
||
|
||
// 如果元素已存在,更新它;否则添加新元素
|
||
if (element.id && laserPointerElements.value.has(element.id)) {
|
||
laserPointerElements.value.set(element.id, element);
|
||
} else {
|
||
// 为远程元素生成新的ID(避免ID冲突)
|
||
const newElementId = generateElementId();
|
||
element.id = newElementId;
|
||
element.timestamp = Date.now();
|
||
|
||
laserPointerElements.value.set(newElementId, element);
|
||
|
||
// 为远程元素设置定时器
|
||
const timeoutId = setTimeout(() => {
|
||
removeLaserElement(newElementId);
|
||
}, laserPointerConfig.duration);
|
||
|
||
laserPointerTimeouts.value.set(newElementId, timeoutId);
|
||
}
|
||
|
||
// 重新绘制
|
||
redrawLaserElements();
|
||
redrawEnlargedLaserElements();
|
||
}
|
||
|
||
//文件上传
|
||
async function fileUploadHandle(){
|
||
fileListRef.value.show()
|
||
}
|
||
|
||
//摄像头打开下拉框触发
|
||
async function handleCameraVisibleChange(e){
|
||
try {
|
||
if(e){
|
||
// 请求摄像头权限
|
||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||
// 获取设备列表
|
||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||
// 过滤摄像头设备
|
||
cameraDevices.value = devices.filter(device => device.kind === 'videoinput');
|
||
// 重要:立即停止临时媒体流,避免占用摄像头
|
||
stream.getTracks().forEach(track => {
|
||
track.stop();
|
||
console.log('临时摄像头轨道已停止');
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('摄像头访问失败:', error);
|
||
// 使用更友好的方式提示用户
|
||
errorHandling(error,'摄像头');
|
||
// 清空设备列表
|
||
cameraDevices.value = [];
|
||
}
|
||
}
|
||
|
||
// 处理摄像头设备选择
|
||
async function handleCameraCommand(deviceId) {
|
||
if (deviceId === 'refresh') {
|
||
await handleCameraVisibleChange(true);
|
||
ElMessage.success('设备列表已刷新');
|
||
return;
|
||
}
|
||
selectedCameraId.value = deviceId;
|
||
// 如果摄像头已经开启,重新开启以应用新设备
|
||
if (cameraEnabled.value) {
|
||
await switchCameraDevice(deviceId);
|
||
} else {
|
||
// 如果摄像头未开启,直接开启选中的设备
|
||
await enableCameraWithDevice(deviceId);
|
||
}
|
||
ElMessage.success(`已切换到摄像头: ${getDeviceName(cameraDevices.value, deviceId)}`);
|
||
}
|
||
|
||
//麦克风打开下拉框触发
|
||
async function handleMicrophoneVisibleChange(e){
|
||
try {
|
||
if(e){
|
||
// 请求麦克风权限
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
// 获取设备列表
|
||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||
// 过滤麦克风设备
|
||
microphoneDevices.value = devices.filter(device => device.kind === 'audioinput');
|
||
// 停止所有轨道来关闭临时的麦克风访问
|
||
stream.getTracks().forEach(track => track.stop());
|
||
}
|
||
} catch (error) {
|
||
console.error('麦克风访问失败:', error);
|
||
// 使用更友好的方式提示用户
|
||
errorHandling(error,'麦克风');
|
||
// 清空设备列表
|
||
microphoneDevices.value = [];
|
||
}
|
||
}
|
||
|
||
// 处理麦克风设备选择
|
||
async function handleMicrophoneCommand(deviceId) {
|
||
if (deviceId === 'refresh') {
|
||
await handleMicrophoneVisibleChange();
|
||
ElMessage.success('设备列表已刷新');
|
||
return;
|
||
}
|
||
selectedMicrophoneId.value = deviceId;
|
||
// 如果麦克风已经开启,重新开启以应用新设备
|
||
if (microphoneEnabled.value) {
|
||
await switchMicrophoneDevice(deviceId);
|
||
} else {
|
||
// 如果麦克风未开启,直接开启选中的设备
|
||
await enableMicrophoneWithDevice(deviceId);
|
||
}
|
||
ElMessage.success(`已切换到麦克风: ${getDeviceName(microphoneDevices.value, deviceId)}`);
|
||
}
|
||
|
||
// 使用指定设备开启摄像头
|
||
async function enableCameraWithDevice(deviceId) {
|
||
try {
|
||
// 更新设备配置
|
||
room.options.videoCaptureDefaults.deviceId = deviceId;
|
||
// 开启摄像头
|
||
await room.localParticipant.setCameraEnabled(true);
|
||
cameraEnabled.value = true;
|
||
// 手动获取并附加视频轨道
|
||
setTimeout(() => {
|
||
attachLocalCameraTrack();
|
||
}, 200);
|
||
return true;
|
||
} catch (error) {
|
||
ElMessage.error(`使用指定设备开启摄像头失败`);
|
||
try {
|
||
if (cameraEnabled.value) {
|
||
await room.localParticipant.setCameraEnabled(true);
|
||
}
|
||
} catch (e) {
|
||
cameraEnabled.value = false;
|
||
selectedCameraId.value = '';
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 使用指定设备开启麦克风
|
||
async function enableMicrophoneWithDevice(deviceId) {
|
||
try {
|
||
// 更新设备配置
|
||
room.options.audioCaptureDefaults.deviceId = deviceId;
|
||
// 开启麦克风
|
||
await room.localParticipant.setMicrophoneEnabled(true);
|
||
microphoneEnabled.value = true;
|
||
return true;
|
||
} catch (error) {
|
||
console.error('使用指定设备开启麦克风失败:', error);
|
||
microphoneEnabled.value = false;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 切换摄像头设备
|
||
async function switchCameraDevice(deviceId) {
|
||
try {
|
||
// 先关闭当前摄像头
|
||
await room.localParticipant.setCameraEnabled(false);
|
||
// 更新设备配置
|
||
room.options.videoCaptureDefaults.deviceId = deviceId;
|
||
// 重新开启摄像头
|
||
await room.localParticipant.setCameraEnabled(true);
|
||
// 手动获取并附加视频轨道
|
||
setTimeout(() => {
|
||
attachLocalCameraTrack();
|
||
}, 200);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('切换摄像头设备失败:', error);
|
||
// 如果切换失败,尝试重新开启之前的设备
|
||
try {
|
||
await room.localParticipant.setCameraEnabled(true);
|
||
} catch (e) {
|
||
cameraEnabled.value = false;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 切换麦克风设备
|
||
async function switchMicrophoneDevice(deviceId) {
|
||
try {
|
||
// 先关闭当前麦克风
|
||
await room.localParticipant.setMicrophoneEnabled(false);
|
||
// 更新设备配置
|
||
room.options.audioCaptureDefaults.deviceId = deviceId;
|
||
// 重新开启麦克风
|
||
await room.localParticipant.setMicrophoneEnabled(true);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('切换麦克风设备失败:', error);
|
||
// 如果切换失败,尝试重新开启之前的设备
|
||
try {
|
||
await room.localParticipant.setMicrophoneEnabled(true);
|
||
} catch (e) {
|
||
microphoneEnabled.value = false;
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 初始化白板MQTT连接
|
||
async function initMqtt() {
|
||
try {
|
||
// 使用随机客户端ID连接
|
||
const clientId = `whiteboard_${Date.now()}`;
|
||
await mqttClient.connect(clientId);
|
||
console.log('MQTT连接成功,客户端ID:', clientId);
|
||
// 订阅白板主题
|
||
subscribeToWhiteboardTopic();
|
||
} catch (error) {
|
||
console.error('MQTT连接失败:', error);
|
||
ElMessage.error('白板同步连接失败');
|
||
}
|
||
}
|
||
|
||
// 订阅白板主题
|
||
function subscribeToWhiteboardTopic() {
|
||
try {
|
||
mqttClient.subscribe(`xSynergy/shareWhiteboard/${room.name}`, handleWhiteboardMessage);
|
||
} catch (error) {
|
||
console.error('订阅白板主题失败:', error);
|
||
}
|
||
}
|
||
|
||
// 初始化激光笔MQTT连接
|
||
async function initToLaserPointerMqtt() {
|
||
try {
|
||
// 使用随机客户端ID连接
|
||
const clientId = `toLaserPointer_${Date.now()}`;
|
||
await mqttClient.connect(clientId);
|
||
console.log('MQTT连接(toLaserPointer)成功,客户端ID:', clientId);
|
||
//订阅激光笔主题
|
||
subscribeToLaserPointerTopic()
|
||
} catch (error) {
|
||
console.error('MQTT连接失败:', error);
|
||
ElMessage.error('激光笔同步连接失败');
|
||
}
|
||
}
|
||
|
||
// 处理白板消息
|
||
function handleWhiteboardMessage(payload, topic) {
|
||
try {
|
||
const messageStr = payload.toString();
|
||
const data = JSON.parse(messageStr);
|
||
// 只处理当前房间的消息
|
||
if (data.roomId !== room.name) return;
|
||
// 忽略自己发送的消息
|
||
if (data.sender === hostUid.value) return;
|
||
switch (data.type) {
|
||
case WHITEBOARD_MESSAGE_TYPES.OPEN:
|
||
handleRemoteWhiteboardOpen(data);
|
||
break;
|
||
case WHITEBOARD_MESSAGE_TYPES.CLOSE:
|
||
handleRemoteWhiteboardClose(data);
|
||
break;
|
||
case WHITEBOARD_MESSAGE_TYPES.SYNC:
|
||
handleWhiteboardSync(data);
|
||
break;
|
||
default:
|
||
console.warn('未知的白板消息类型:', data.type);
|
||
}
|
||
} catch (error) {
|
||
console.error('处理白板消息失败:', error);
|
||
}
|
||
}
|
||
|
||
// 处理远程打开白板
|
||
function handleRemoteWhiteboardOpen(data) {
|
||
ElMessage.info(`${data.senderName || data.sender} 开启了白板`);
|
||
isWhiteboardActive.value = true;
|
||
// 如果正在屏幕共享,自动停止
|
||
if (isScreenSharing.value) {
|
||
room.localParticipant.setScreenShareEnabled(false);
|
||
isScreenSharing.value = false;
|
||
}
|
||
// 如果正在放大视图,自动关闭
|
||
if (enlargedParticipant.value) {
|
||
closeEnlargedView();
|
||
}
|
||
}
|
||
|
||
// 处理远程关闭白板
|
||
function handleRemoteWhiteboardClose(data) {
|
||
ElMessage.info(`${data.senderName || data.sender} 关闭了白板`);
|
||
if(data.roomType == '1'){
|
||
isWhiteboardActive.value = false;
|
||
}
|
||
}
|
||
|
||
// 处理白板同步消息
|
||
function handleWhiteboardSync(data) {
|
||
// 这里可以处理白板内容的同步
|
||
if (whiteboardRef.value && data.payload) {
|
||
// 调用白板组件的同步方法
|
||
if (whiteboardRef.value.syncData) {
|
||
whiteboardRef.value.syncData(data.payload);
|
||
}
|
||
}
|
||
}
|
||
|
||
//邀请进入房间
|
||
async function inviterJoinRoom(){
|
||
inviterJoinRoomRef.value.show()
|
||
}
|
||
|
||
// 确认选择房间
|
||
async function handleConfirmSelection(userInfo){
|
||
if(userInfo.length < 0){
|
||
ElMessage.error('请选择加入房间的人员')
|
||
return
|
||
}
|
||
const joinUserIds = userInfo.map(item => item.uid)
|
||
await getInvite(room.name,{user_uids:joinUserIds, participant_role: "participant"})
|
||
}
|
||
|
||
function publishWhiteboardMessage(type, payload = {}) {
|
||
try {
|
||
const message = {
|
||
type,
|
||
roomType: route.query.type,//用于判断是参与者还是发起者
|
||
roomId: roomId.value,
|
||
sender: hostUid.value,
|
||
senderName: hostUid.value,
|
||
timestamp: Date.now(),
|
||
payload
|
||
};
|
||
mqttClient.publish(`xSynergy/shareWhiteboard/${room.name}`, message);
|
||
return true;
|
||
} catch (error) {
|
||
console.error('发布白板消息失败:', error);
|
||
ElMessage.warning('消息发送失败,但白板功能正常');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function toggleWhiteboard() {
|
||
// if(cameraEnabled.value) {
|
||
// ElMessage.error('请先关闭摄像头才能使用白板');
|
||
// return;
|
||
// }
|
||
|
||
// if(hasActiveScreenShare.value){
|
||
// ElMessage.error('请先关闭屏幕共享');
|
||
// return;
|
||
// }
|
||
|
||
// roomId.value = room.name
|
||
if (isWhiteboardActive.value) {
|
||
// 如果白板已经激活,点击则退出白板
|
||
await exitWhiteboard();
|
||
} else {
|
||
// 否则开启白板
|
||
await startWhiteboard();
|
||
}
|
||
}
|
||
|
||
async function startWhiteboard() {
|
||
try {
|
||
// 如果正在屏幕共享,先停止
|
||
if (isScreenSharing.value) {
|
||
await room.localParticipant.setScreenShareEnabled(false);
|
||
isScreenSharing.value = false;
|
||
ElMessage.info('已停止屏幕共享,开启白板');
|
||
}
|
||
// 如果正在放大视图,先关闭
|
||
if (enlargedParticipant.value) {
|
||
closeEnlargedView();
|
||
}
|
||
|
||
// 激活白板状态
|
||
isWhiteboardActive.value = true;
|
||
const success = publishWhiteboardMessage(WHITEBOARD_MESSAGE_TYPES.OPEN, {
|
||
action: 'open',
|
||
whiteboardId: roomId.value,
|
||
roomName: roomName.value
|
||
});
|
||
if (success) {
|
||
ElMessage.success('白板已开启,已通知其他参会者');
|
||
} else {
|
||
ElMessage.success('白板已开启');
|
||
}
|
||
} catch (error) {
|
||
console.error('开启白板失败:', error);
|
||
ElMessage.error('开启白板失败');
|
||
}
|
||
}
|
||
|
||
async function exitWhiteboard() {
|
||
try {
|
||
// 发布关闭白板消息
|
||
publishWhiteboardMessage(WHITEBOARD_MESSAGE_TYPES.CLOSE, {
|
||
action: 'close',
|
||
whiteboardId: roomId.value,
|
||
roomName: roomName.value
|
||
});
|
||
// 执行退出白板逻辑
|
||
isWhiteboardActive.value = false;
|
||
// 可以在这里添加白板清理逻辑
|
||
if (whiteboardRef.value && whiteboardRef.value.cleanup) {
|
||
whiteboardRef.value.cleanup();
|
||
}
|
||
ElMessage.success('已退出白板');
|
||
} catch (error) {
|
||
if (error !== 'cancel') {
|
||
console.error('退出白板失败:', error);
|
||
ElMessage.error('退出白板失败');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 设置视频元素引用
|
||
function setParticipantVideoRef(el, identity, type) {
|
||
if (!el) return;
|
||
if (!videoElementsMap.value.has(identity)) {
|
||
videoElementsMap.value.set(identity, {});
|
||
}
|
||
const elements = videoElementsMap.value.get(identity);
|
||
elements[type] = el;
|
||
videoElementsMap.value.set(identity, elements);
|
||
// 如果已经有轨道数据,立即附加
|
||
const participantData = remoteParticipants.value.get(identity);
|
||
if (participantData) {
|
||
if (type === 'camera' && participantData.cameraTrack) {
|
||
attachTrackToVideo(el, participantData.cameraTrack);
|
||
} else if (type === 'screen' && participantData.screenTrack) {
|
||
attachTrackToVideo(el, participantData.screenTrack);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 设置音频元素引用
|
||
function setParticipantAudioRef(el, identity) {
|
||
if (!el) return;
|
||
audioElementsMap.value.set(identity, el);
|
||
// 如果已经有音频轨道数据,立即附加
|
||
const participantData = remoteParticipants.value.get(identity);
|
||
if (participantData && participantData.audioTrack) {
|
||
attachTrackToAudio(el, participantData.audioTrack);
|
||
}
|
||
}
|
||
|
||
// 设置屏幕共享视频引用
|
||
function setScreenShareVideoRef(el) {
|
||
if (!el) return;
|
||
screenShareVideo.value = el;
|
||
// 如果已经有屏幕共享轨道,立即附加
|
||
if (activeScreenShareTrack.value) {
|
||
attachTrackToVideo(el, activeScreenShareTrack.value);
|
||
}
|
||
// 初始化激光笔 Canvas
|
||
nextTick(() => {
|
||
initLaserPointerCanvas();
|
||
});
|
||
}
|
||
|
||
// 设置事件监听器
|
||
function setupRoomListeners() {
|
||
room.removeAllListeners();
|
||
room
|
||
.on(RoomEvent.Connected, handleConnected)
|
||
.on(RoomEvent.Disconnected, handleDisconnected)
|
||
.on(RoomEvent.Reconnected, handleReconnected)
|
||
.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
||
.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
|
||
.on(RoomEvent.ParticipantConnected, handleParticipantConnected)
|
||
.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
|
||
.on(RoomEvent.LocalTrackPublished, handleLocalTrackPublished)
|
||
.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnpublished)
|
||
.on(RoomEvent.TrackMuted, handleTrackMuted)
|
||
.on(RoomEvent.TrackUnmuted, handleTrackUnmuted)
|
||
.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged)
|
||
.on(RoomEvent.DataReceived, handleDataReceived)
|
||
.on(RoomEvent.ConnectionStateChanged, handleConnectionStateChanged);
|
||
}
|
||
|
||
// 事件处理函数
|
||
async function handleConnected() {
|
||
roomId.value = room.name
|
||
await initMqtt();
|
||
await initToLaserPointerMqtt();
|
||
await initMqttFileUploadSucc() ;
|
||
await initMqttFileConversionStatus();
|
||
await initMqttFilePreview();
|
||
await initMqttEnlargeVideo();
|
||
status.value = false;
|
||
ElMessage.success('已成功连接到房间');
|
||
// console.log('房间连接成功', {
|
||
// localParticipant: room.localParticipant.identity,
|
||
// remoteParticipants: room.remoteParticipants.size
|
||
// });
|
||
// 初始化现有远程参与者
|
||
room.remoteParticipants.forEach(participant => {
|
||
addRemoteParticipant(participant);
|
||
setupParticipantListeners(participant);
|
||
// 立即检查并更新参与者的轨道状态
|
||
updateParticipantTracks(participant);
|
||
});
|
||
}
|
||
|
||
function handleDisconnected(reason) {
|
||
status.value = true;
|
||
cameraEnabled.value = false;
|
||
microphoneEnabled.value = false;
|
||
isScreenSharing.value = false;
|
||
remoteParticipants.value.clear();
|
||
videoElementsMap.value.clear();
|
||
audioElementsMap.value.clear();
|
||
screenSharingUser.value = '';
|
||
activeScreenShareTrack.value = null;
|
||
enlargedParticipant.value = null;
|
||
ElMessage.error('连接已断开');
|
||
}
|
||
|
||
// 处理轨道订阅事件
|
||
function handleTrackSubscribed(track, publication, participant) {
|
||
if (track) {
|
||
if (track.kind === Track.Kind.Video) {
|
||
// console.log(`视频轨道订阅: ${participant.identity} - ${publication.source}`, {
|
||
// track: track,
|
||
// publication: publication
|
||
// });
|
||
|
||
// 更新参与者轨道信息
|
||
updateParticipantTrack(participant, publication.source, track);
|
||
|
||
// 如果是屏幕共享,更新屏幕共享状态
|
||
if (publication.source === Track.Source.ScreenShare) {
|
||
updateScreenShareState(participant, track);
|
||
// 设置全局屏幕共享用户
|
||
globalScreenSharingUser.value = participant.identity;
|
||
isGlobalScreenSharing.value = true;
|
||
}
|
||
|
||
// 立即附加到视频元素(如果元素已存在)
|
||
attachTrackToParticipantVideo(participant.identity, publication.source, track);
|
||
|
||
} else if (track.kind === Track.Kind.Audio) {
|
||
// 处理音频轨道订阅
|
||
updateParticipantAudioTrack(participant, track);
|
||
attachTrackToParticipantAudio(participant.identity, track);
|
||
}
|
||
}
|
||
// 更新参与者的轨道状态
|
||
updateParticipantTracks(participant);
|
||
}
|
||
|
||
function handleTrackUnsubscribed(track, publication, participant) {
|
||
// 移除对应的轨道信息
|
||
if (track.kind === Track.Kind.Video) {
|
||
removeParticipantTrack(participant, publication.source);
|
||
// 清理视频元素
|
||
detachTrackFromParticipantVideo(participant.identity, publication.source);
|
||
|
||
// 如果是屏幕共享,更新屏幕共享状态
|
||
if (publication.source === Track.Source.ScreenShare) {
|
||
updateScreenShareState(participant, null);
|
||
// 清除全局屏幕共享状态
|
||
if (globalScreenSharingUser.value === participant.identity) {
|
||
globalScreenSharingUser.value = '';
|
||
isGlobalScreenSharing.value = false;
|
||
}
|
||
}
|
||
|
||
// 重要:立即更新参与者轨道状态
|
||
updateParticipantTracks(participant);
|
||
|
||
} else if (track.kind === Track.Kind.Audio) {
|
||
// 处理音频轨道取消订阅
|
||
removeParticipantAudioTrack(participant);
|
||
detachTrackFromParticipantAudio(participant.identity);
|
||
// 立即更新参与者轨道状态
|
||
updateParticipantTracks(participant);
|
||
}
|
||
}
|
||
|
||
function handleParticipantConnected(participant) {
|
||
addRemoteParticipant(participant);
|
||
setupParticipantListeners(participant);
|
||
// 立即检查参与者的轨道状态
|
||
updateParticipantTracks(participant);
|
||
ElMessage.info(`新用户加入: ${participant.identity}`);
|
||
}
|
||
|
||
function handleParticipantDisconnected(participant) {
|
||
// 如果离开的用户是屏幕共享者,清除屏幕共享状态
|
||
if (participant.identity === screenSharingUser.value) {
|
||
screenSharingUser.value = '';
|
||
activeScreenShareTrack.value = null;
|
||
if (screenShareVideo.value && screenShareVideo.value.srcObject) {
|
||
screenShareVideo.value.srcObject = null;
|
||
}
|
||
}
|
||
// 如果离开的用户是全局屏幕共享者,清除全局状态
|
||
if (participant.identity === globalScreenSharingUser.value) {
|
||
globalScreenSharingUser.value = '';
|
||
isGlobalScreenSharing.value = false;
|
||
}
|
||
|
||
// 如果离开的用户是当前放大的用户,关闭放大视图
|
||
if (enlargedParticipant.value && enlargedParticipant.value.identity === participant.identity) {
|
||
closeEnlargedView();
|
||
}
|
||
|
||
removeRemoteParticipant(participant);
|
||
ElMessage.info(`用户离开: ${participant.identity}`);
|
||
}
|
||
|
||
function handleLocalTrackPublished(publication) {
|
||
if (publication.track) {
|
||
if (publication.kind === Track.Kind.Video) {
|
||
if (publication.source === Track.Source.Camera) {
|
||
attachLocalVideoTrack(publication.track);
|
||
} else if (publication.source === Track.Source.ScreenShare) {
|
||
// 本地用户开始屏幕共享
|
||
screenSharingUser.value = room.localParticipant.identity;
|
||
activeScreenShareTrack.value = publication.track;
|
||
// 设置全局屏幕共享状态
|
||
globalScreenSharingUser.value = room.localParticipant.identity;
|
||
isGlobalScreenSharing.value = true;
|
||
if (screenShareVideo.value) {
|
||
attachTrackToVideo(screenShareVideo.value, publication.track);
|
||
}
|
||
}
|
||
} else if (publication.kind === Track.Kind.Audio) {
|
||
// 本地音频轨道发布
|
||
microphoneEnabled.value = true;
|
||
console.log('本地音频轨道已发布');
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleLocalTrackUnpublished(publication) {
|
||
if (publication.kind === Track.Kind.Video) {
|
||
if (publication.source === Track.Source.Camera) {
|
||
cameraEnabled.value = false;
|
||
} else if (publication.source === Track.Source.ScreenShare) {
|
||
// 本地用户停止屏幕共享
|
||
if (screenSharingUser.value === room.localParticipant.identity) {
|
||
isScreenSharing.value = false;
|
||
screenSharingUser.value = '';
|
||
activeScreenShareTrack.value = null;
|
||
// 清除全局屏幕共享状态
|
||
if (globalScreenSharingUser.value === room.localParticipant.identity) {
|
||
globalScreenSharingUser.value = '';
|
||
isGlobalScreenSharing.value = false;
|
||
}
|
||
if (screenShareVideo.value && screenShareVideo.value.srcObject) {
|
||
screenShareVideo.value.srcObject = null;
|
||
}
|
||
}
|
||
}
|
||
} else if (publication.kind === Track.Kind.Audio) {
|
||
microphoneEnabled.value = false;
|
||
}
|
||
}
|
||
|
||
function handleTrackMuted(publication, participant) {
|
||
updateParticipantTracks(participant);
|
||
}
|
||
|
||
// 处理轨道取消静音事件
|
||
function handleTrackUnmuted(publication, participant) {
|
||
updateParticipantTracks(participant);
|
||
}
|
||
|
||
// 设置参与者事件监听器
|
||
function setupParticipantListeners(participant) {
|
||
participant
|
||
.on(ParticipantEvent.TrackSubscribed, (track, publication) => {
|
||
// console.log(`轨道订阅: ${publication.source} - ${participant.identity}`);
|
||
handleTrackSubscribed(track, publication, participant);
|
||
})
|
||
.on(ParticipantEvent.TrackUnsubscribed, (track, publication) => {
|
||
// console.log(`轨道取消订阅: ${publication.source} - ${participant.identity}`);
|
||
handleTrackUnsubscribed(track, publication, participant);
|
||
})
|
||
.on(ParticipantEvent.TrackMuted, (publication) => {
|
||
// console.log(`轨道静音: ${publication.source} - ${participant.identity}`);
|
||
handleTrackMuted(publication, participant);
|
||
})
|
||
.on(ParticipantEvent.TrackUnmuted, (publication) => {
|
||
// console.log(`轨道取消静音: ${publication.source} - ${participant.identity}`);
|
||
handleTrackUnmuted(publication, participant);
|
||
})
|
||
.on(ParticipantEvent.IsSpeakingChanged, (speaking) => {
|
||
updateParticipantSpeaking(participant, speaking);
|
||
})
|
||
// 添加轨道发布状态变化监听
|
||
.on(ParticipantEvent.TrackPublished, (publication) => {
|
||
updateParticipantTracks(participant);
|
||
})
|
||
.on(ParticipantEvent.TrackUnpublished, (publication) => {
|
||
updateParticipantTracks(participant);
|
||
});
|
||
}
|
||
|
||
// 处理活动说话者变化事件
|
||
function handleActiveSpeakersChanged(speakers) {
|
||
// 更新本地说话状态
|
||
const localIsSpeaking = speakers.some(speaker => speaker.identity === room.localParticipant.identity);
|
||
isLocalSpeaking.value = localIsSpeaking;
|
||
// 更新远程参与者说话状态
|
||
remoteParticipants.value.forEach((data, identity) => {
|
||
const isSpeaking = speakers.some(speaker => speaker.identity === identity);
|
||
if (data.isSpeaking !== isSpeaking) {
|
||
data.isSpeaking = isSpeaking;
|
||
remoteParticipants.value.set(identity, { ...data });
|
||
}
|
||
});
|
||
}
|
||
|
||
// 更新屏幕共享状态
|
||
function updateScreenShareState(participant, track) {
|
||
if (track) {
|
||
// 有新的屏幕共享轨道
|
||
screenSharingUser.value = participant.identity;
|
||
activeScreenShareTrack.value = track;
|
||
// 附加到屏幕共享视频元素
|
||
if (screenShareVideo.value) {
|
||
attachTrackToVideo(screenShareVideo.value, track);
|
||
}
|
||
} else {
|
||
// 如果当前屏幕共享者是该参与者,清除屏幕共享状态
|
||
if (participant.identity === screenSharingUser.value) {
|
||
screenSharingUser.value = '';
|
||
activeScreenShareTrack.value = null;
|
||
if (screenShareVideo.value && screenShareVideo.value.srcObject) {
|
||
screenShareVideo.value.srcObject = null;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 参与者管理函数
|
||
function addRemoteParticipant(participant) {
|
||
if (!participant || participant.identity === room.localParticipant?.identity) {
|
||
return;
|
||
}
|
||
const participantData = {
|
||
identity: participant.identity,
|
||
cameraTrack: null,
|
||
screenTrack: null,
|
||
audioTrack: null,
|
||
hasCameraTrack: false,
|
||
hasScreenTrack: false,
|
||
audioEnabled: false,
|
||
videoEnabled: participant.isCameraEnabled,
|
||
isSpeaking: false
|
||
};
|
||
remoteParticipants.value.set(participant.identity, participantData);
|
||
// console.log("添加远程参与者:", participant.identity);
|
||
}
|
||
|
||
function removeRemoteParticipant(participant) {
|
||
if (remoteParticipants.value.has(participant.identity)) {
|
||
// 清理音视频元素
|
||
const identity = participant.identity;
|
||
detachTrackFromParticipantVideo(identity, 'camera');
|
||
detachTrackFromParticipantVideo(identity, 'screen');
|
||
detachTrackFromParticipantAudio(identity);
|
||
remoteParticipants.value.delete(identity);
|
||
videoElementsMap.value.delete(identity);
|
||
audioElementsMap.value.delete(identity);
|
||
}
|
||
}
|
||
|
||
// 更新参与者轨道信息
|
||
function updateParticipantTrack(participant, source, track) {
|
||
const data = remoteParticipants.value.get(participant.identity);
|
||
if (!data) return;
|
||
if (source === Track.Source.Camera) {
|
||
data.cameraTrack = track;
|
||
data.hasCameraTrack = true;
|
||
} else if (source === Track.Source.ScreenShare) {
|
||
data.screenTrack = track;
|
||
data.hasScreenTrack = true;
|
||
}
|
||
remoteParticipants.value.set(participant.identity, { ...data });
|
||
}
|
||
|
||
function removeParticipantTrack(participant, source) {
|
||
const data = remoteParticipants.value.get(participant.identity);
|
||
if (!data) return;
|
||
if (source === Track.Source.Camera) {
|
||
data.cameraTrack = null;
|
||
data.hasCameraTrack = false;
|
||
} else if (source === Track.Source.ScreenShare) {
|
||
data.screenTrack = null;
|
||
data.hasScreenTrack = false;
|
||
}
|
||
remoteParticipants.value.set(participant.identity, { ...data });
|
||
}
|
||
|
||
// 更新参与者音频轨道信息
|
||
function updateParticipantAudioTrack(participant, track) {
|
||
const data = remoteParticipants.value.get(participant.identity);
|
||
if (!data) return;
|
||
data.audioTrack = track;
|
||
data.audioEnabled = true;
|
||
remoteParticipants.value.set(participant.identity, { ...data });
|
||
}
|
||
|
||
function removeParticipantAudioTrack(participant) {
|
||
const data = remoteParticipants.value.get(participant.identity);
|
||
if (!data) return;
|
||
data.audioTrack = null;
|
||
data.audioEnabled = false;
|
||
remoteParticipants.value.set(participant.identity, { ...data });
|
||
}
|
||
|
||
// 附加轨道到视频元素
|
||
function attachTrackToVideo(videoElement, track) {
|
||
if (!videoElement || !track) return;
|
||
try {
|
||
// 使用 MediaStream 方式附加轨道
|
||
const mediaStream = new MediaStream();
|
||
mediaStream.addTrack(track.mediaStreamTrack);
|
||
videoElement.srcObject = mediaStream;
|
||
} catch (error) {
|
||
console.error('附加轨道到视频元素失败:', error);
|
||
}
|
||
}
|
||
|
||
// 附加轨道到音频元素
|
||
function attachTrackToAudio(audioElement, track) {
|
||
if (!audioElement || !track) return;
|
||
try {
|
||
const mediaStream = new MediaStream();
|
||
mediaStream.addTrack(track.mediaStreamTrack);
|
||
audioElement.srcObject = mediaStream;
|
||
// 确保音频元素可以播放
|
||
audioElement.play().catch(error => {
|
||
console.warn('音频播放失败:', error);
|
||
});
|
||
} catch (error) {
|
||
console.error('附加轨道到音频元素失败:', error);
|
||
}
|
||
}
|
||
|
||
// 附加轨道到参与者的视频元素
|
||
function attachTrackToParticipantVideo(identity, source, track) {
|
||
const videoElements = videoElementsMap.value.get(identity);
|
||
if (!videoElements) return;
|
||
const type = source === Track.Source.Camera ? 'camera' : 'screen';
|
||
const videoElement = videoElements[type];
|
||
if (videoElement) {
|
||
attachTrackToVideo(videoElement, track);
|
||
}
|
||
}
|
||
|
||
// 附加轨道到参与者的音频元素
|
||
function attachTrackToParticipantAudio(identity, track) {
|
||
const audioElement = audioElementsMap.value.get(identity);
|
||
if (audioElement) {
|
||
attachTrackToAudio(audioElement, track);
|
||
}
|
||
}
|
||
|
||
// 从参与者的视频元素分离轨道
|
||
function detachTrackFromParticipantVideo(identity, source) {
|
||
const videoElements = videoElementsMap.value.get(identity);
|
||
if (!videoElements) return;
|
||
const type = source === Track.Source.Camera ? 'camera' : 'screen';
|
||
const videoElement = videoElements[type];
|
||
if (videoElement && videoElement.srcObject) {
|
||
videoElement.srcObject = null;
|
||
}
|
||
}
|
||
|
||
// 从参与者的音频元素分离轨道
|
||
function detachTrackFromParticipantAudio(identity) {
|
||
const audioElement = audioElementsMap.value.get(identity);
|
||
if (audioElement && audioElement.srcObject) {
|
||
audioElement.srcObject = null;
|
||
}
|
||
}
|
||
|
||
function updateParticipantTracks(participant) {
|
||
const data = remoteParticipants.value.get(participant.identity);
|
||
if (!data) return;
|
||
|
||
// 重置轨道状态
|
||
let hasCamera = false;
|
||
let hasScreen = false;
|
||
let hasAudio = false;
|
||
|
||
// 检查已发布的轨道 - 只统计已订阅且未静音的轨道
|
||
participant.videoTrackPublications.forEach(publication => {
|
||
if (publication.isSubscribed && publication.track && !publication.isMuted) {
|
||
if (publication.source === Track.Source.Camera) {
|
||
hasCamera = true;
|
||
// 确保轨道信息更新
|
||
if (!data.cameraTrack) {
|
||
data.cameraTrack = publication.track;
|
||
}
|
||
} else if (publication.source === Track.Source.ScreenShare) {
|
||
hasScreen = true;
|
||
if (!data.screenTrack) {
|
||
data.screenTrack = publication.track;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 检查音频轨道 - 只统计已订阅且未静音的轨道
|
||
participant.audioTrackPublications.forEach(publication => {
|
||
if (publication.isSubscribed && publication.track && !publication.isMuted) {
|
||
hasAudio = true;
|
||
if (!data.audioTrack) {
|
||
data.audioTrack = publication.track;
|
||
}
|
||
}
|
||
});
|
||
|
||
// 更新数据
|
||
data.hasCameraTrack = hasCamera;
|
||
data.hasScreenTrack = hasScreen;
|
||
data.audioEnabled = hasAudio;
|
||
data.videoEnabled = participant.isCameraEnabled;
|
||
|
||
// 如果没有摄像头轨道,清空相关数据
|
||
if (!hasCamera) {
|
||
data.cameraTrack = null;
|
||
}
|
||
|
||
// 如果没有屏幕共享轨道,清空相关数据
|
||
if (!hasScreen) {
|
||
data.screenTrack = null;
|
||
}
|
||
|
||
// 如果没有音频轨道,清空相关数据
|
||
if (!hasAudio) {
|
||
data.audioTrack = null;
|
||
}
|
||
|
||
remoteParticipants.value.set(participant.identity, { ...data });
|
||
|
||
// console.log(`更新参与者轨道状态: ${participant.identity}`, {
|
||
// hasCameraTrack: data.hasCameraTrack,
|
||
// hasScreenTrack: data.hasScreenTrack,
|
||
// audioEnabled: data.audioEnabled
|
||
// });
|
||
}
|
||
|
||
function updateParticipantSpeaking(participant, speaking) {
|
||
const data = remoteParticipants.value.get(participant.identity);
|
||
if (data && data.isSpeaking !== speaking) {
|
||
data.isSpeaking = speaking;
|
||
remoteParticipants.value.set(participant.identity, { ...data });
|
||
}
|
||
}
|
||
|
||
// 视频轨道处理函数
|
||
function attachLocalVideoTrack(track) {
|
||
if (localVideo.value && track) {
|
||
try {
|
||
const mediaStream = new MediaStream();
|
||
mediaStream.addTrack(track.mediaStreamTrack);
|
||
localVideo.value.srcObject = mediaStream;
|
||
cameraEnabled.value = true;
|
||
|
||
// 确保视频可以播放
|
||
localVideo.value.play().catch(error => {
|
||
console.warn('本地视频播放失败:', error);
|
||
});
|
||
|
||
console.log('本地视频轨道已附加', track);
|
||
} catch (error) {
|
||
console.error('附加本地视频轨道失败:', error);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 媒体控制函数
|
||
async function enableCamera() {
|
||
try {
|
||
await room.localParticipant.setCameraEnabled(true);
|
||
cameraEnabled.value = true;
|
||
return true;
|
||
} catch (error) {
|
||
console.error('开启摄像头失败:', error);
|
||
cameraEnabled.value = false;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function enableMicrophone() {
|
||
try {
|
||
await room.localParticipant.setMicrophoneEnabled(true);
|
||
microphoneEnabled.value = true;
|
||
return true;
|
||
} catch (error) {
|
||
console.error('开启麦克风失败:', error);
|
||
microphoneEnabled.value = false;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function toggleCamera() {
|
||
try {
|
||
if (cameraEnabled.value) {
|
||
// 关闭摄像头
|
||
await room.localParticipant.setCameraEnabled(false);
|
||
cameraEnabled.value = false;
|
||
// 清理本地视频元素
|
||
if (localVideo.value && localVideo.value.srcObject) {
|
||
localVideo.value.srcObject.getTracks().forEach(track => track.stop());
|
||
localVideo.value.srcObject = null;
|
||
}
|
||
// 清空选中的视屏设备ID
|
||
selectedCameraId.value = '';
|
||
ElMessage.info('摄像头已关闭');
|
||
if (enlargedParticipant.value) {
|
||
closeEnlargedView();
|
||
}
|
||
// 强制更新一次本地参与者状态
|
||
updateParticipantTracks(room.localParticipant);
|
||
} else {
|
||
// 确保视频元素存在
|
||
if (!localVideo.value) {
|
||
console.warn('本地视频元素未找到,等待DOM更新');
|
||
await nextTick();
|
||
}
|
||
// 开启摄像头
|
||
// 确保有视屏输入设备权限和设备列表
|
||
if (cameraDevices.value.length === 0) {
|
||
// 如果没有设备列表,先获取
|
||
await handleCameraVisibleChange(true);
|
||
}
|
||
if (cameraDevices.value.length === 0) {
|
||
ElMessage.error('未找到可用的摄像头设备');
|
||
return;
|
||
}
|
||
|
||
// 自动选择第一个可用设备(如果当前没有选中设备)
|
||
let deviceToUse = selectedCameraId.value;
|
||
if (!deviceToUse && cameraDevices.value.length > 0) {
|
||
deviceToUse = cameraDevices.value[0].deviceId;
|
||
selectedCameraId.value = deviceToUse;
|
||
}
|
||
|
||
if (deviceToUse) {
|
||
await enableCameraWithDevice(deviceToUse);
|
||
ElMessage.success(`摄像头已开启 - ${getDeviceName(cameraDevices.value, deviceToUse)}`);
|
||
} else {
|
||
// 使用默认方式开启
|
||
await room.localParticipant.setCameraEnabled(true);
|
||
cameraEnabled.value = true;
|
||
// 手动获取并附加视频轨道
|
||
setTimeout(() => {
|
||
attachLocalCameraTrack();
|
||
}, 200);
|
||
ElMessage.success('摄像头已开启');
|
||
}
|
||
|
||
// 重要:确保轨道正确发布给其他用户
|
||
await ensureCameraTrackPublished();
|
||
}
|
||
} catch (error) {
|
||
errorHandling(error,'摄像头');
|
||
}
|
||
}
|
||
|
||
// 确保摄像头轨道正确发布
|
||
async function ensureCameraTrackPublished() {
|
||
try {
|
||
// 等待轨道发布
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
|
||
const videoPublications = Array.from(room.localParticipant.videoTrackPublications.values());
|
||
const cameraPublication = videoPublications.find(pub =>
|
||
pub.source === Track.Source.Camera && pub.track
|
||
);
|
||
|
||
if (cameraPublication) {
|
||
// console.log('摄像头轨道发布状态:', {
|
||
// isSubscribed: cameraPublication.isSubscribed,
|
||
// isEnabled: cameraPublication.isEnabled,
|
||
// track: cameraPublication.track
|
||
// });
|
||
|
||
// 如果轨道存在但未正确附加,重新附加
|
||
if (cameraPublication.track && localVideo.value) {
|
||
attachLocalVideoTrack(cameraPublication.track);
|
||
}
|
||
} else {
|
||
console.warn('未找到摄像头发布轨道');
|
||
}
|
||
} catch (error) {
|
||
console.error('确保摄像头轨道发布失败:', error);
|
||
}
|
||
}
|
||
|
||
async function attachLocalCameraTrack() {
|
||
try {
|
||
// 等待一小段时间确保轨道已经创建
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
// 获取本地参与者的摄像头轨道发布
|
||
const videoPublications = Array.from(room.localParticipant.videoTrackPublications.values());
|
||
const cameraPublication = videoPublications.find(pub =>
|
||
pub.source === Track.Source.Camera && pub.track
|
||
);
|
||
if (cameraPublication && cameraPublication.track) {
|
||
attachLocalVideoTrack(cameraPublication.track);
|
||
} else {
|
||
// 如果没有找到,等待更长时间再检查
|
||
setTimeout(() => {
|
||
const videoPublications = Array.from(room.localParticipant.videoTrackPublications.values());
|
||
const cameraPublication = videoPublications.find(pub =>
|
||
pub.source === Track.Source.Camera && pub.track
|
||
);
|
||
if (cameraPublication && cameraPublication.track) {
|
||
attachLocalVideoTrack(cameraPublication.track);
|
||
} else {
|
||
console.warn('最终未找到摄像头轨道');
|
||
}
|
||
}, 1000);
|
||
}
|
||
} catch (error) {
|
||
console.error('手动附加摄像头轨道失败:', error);
|
||
}
|
||
}
|
||
|
||
async function toggleMicrophone() {
|
||
try {
|
||
if (microphoneEnabled.value) {
|
||
await room.localParticipant.setMicrophoneEnabled(false);
|
||
microphoneEnabled.value = false;
|
||
// 停止所有音频轨道
|
||
const audioPublications = Array.from(room.localParticipant.audioTrackPublications.values());
|
||
for (const publication of audioPublications) {
|
||
if (publication.track) {
|
||
publication.track.stop(); // 停止轨道
|
||
}
|
||
}
|
||
// 清空选中的麦克风设备ID
|
||
selectedMicrophoneId.value = '';
|
||
ElMessage.info('麦克风已关闭');
|
||
} else {
|
||
// 开启麦克风
|
||
// 确保有音频输入设备权限和设备列表
|
||
if (microphoneDevices.value.length === 0) {
|
||
// 如果没有设备列表,先获取
|
||
await handleMicrophoneVisibleChange(true);
|
||
}
|
||
if (microphoneDevices.value.length === 0) {
|
||
// ElMessage.error('未找到可用的麦克风设备');
|
||
return;
|
||
}
|
||
// 自动选择第一个可用设备(如果当前没有选中设备)
|
||
let deviceToUse = selectedMicrophoneId.value;
|
||
if (!deviceToUse && microphoneDevices.value.length > 0) {
|
||
deviceToUse = microphoneDevices.value[0].deviceId;
|
||
selectedMicrophoneId.value = deviceToUse;
|
||
}
|
||
if (deviceToUse) {
|
||
await enableMicrophoneWithDevice(deviceToUse);
|
||
ElMessage.success(`麦克风已开启 - ${getDeviceName(microphoneDevices.value, deviceToUse)}`);
|
||
} else {
|
||
// 使用默认方式开启
|
||
await room.localParticipant.setMicrophoneEnabled(true);
|
||
microphoneEnabled.value = true;
|
||
ElMessage.success('麦克风已开启');
|
||
}
|
||
ElMessage.success('麦克风已开启');
|
||
}
|
||
} catch (error) {
|
||
errorHandling(error, '麦克风');
|
||
// 如果开启失败,确保状态正确
|
||
if (!microphoneEnabled.value) {
|
||
selectedMicrophoneId.value = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
async function toggleScreenShare() {
|
||
try {
|
||
// 检查是否处于放大视频模式
|
||
if (enlargedParticipant.value) {
|
||
ElMessage.error('当前处于视频放大模式,无法进行屏幕共享');
|
||
return;
|
||
}
|
||
if(isWhiteboardActive.value){
|
||
ElMessage.error('请先关闭白板');
|
||
return;
|
||
}
|
||
// 检查是否已经有其他用户在共享屏幕
|
||
if (!isScreenSharing.value && isGlobalScreenSharing.value && globalScreenSharingUser.value !== hostUid.value) {
|
||
ElMessage.error(`当前 ${globalScreenSharingUser.value} 正在共享屏幕,请等待其结束后再共享`);
|
||
return;
|
||
}
|
||
if (isScreenSharing.value) {
|
||
await room.localParticipant.setScreenShareEnabled(false);
|
||
isScreenSharing.value = false;
|
||
// 清除全局屏幕共享状态
|
||
if (globalScreenSharingUser.value === hostUid.value) {
|
||
globalScreenSharingUser.value = '';
|
||
isGlobalScreenSharing.value = false;
|
||
}
|
||
ElMessage.info('屏幕共享已停止');
|
||
} else {
|
||
await room.localParticipant.setScreenShareEnabled(true);
|
||
isScreenSharing.value = true;
|
||
// 设置全局屏幕共享状态
|
||
globalScreenSharingUser.value = hostUid.value;
|
||
isGlobalScreenSharing.value = true;
|
||
ElMessage.success('屏幕共享已开始');
|
||
}
|
||
} catch (error) {
|
||
errorHandling(error,'屏幕共享');
|
||
}
|
||
}
|
||
|
||
function handleScreenShareEnded() {
|
||
// console.log('用户通过浏览器控件停止了屏幕共享');
|
||
isScreenSharing.value = false;
|
||
ElMessage.info('屏幕共享已停止');
|
||
// 移除事件监听器
|
||
room.localParticipant.off('screenShareEnded', handleScreenShareEnded);
|
||
}
|
||
|
||
async function joinRoomBtn() {
|
||
try {
|
||
const res = await getRoomToken({max_participants: 20});
|
||
if(res.meta.code != 200){
|
||
ElMessage.error(res.meta.message);
|
||
return;
|
||
}
|
||
const token = res.data.access_token;
|
||
roomName.value = res.data.room.name
|
||
if (!token) {
|
||
throw new Error('获取 token 失败');
|
||
}
|
||
setupRoomListeners();
|
||
await room.connect(wsURL, token, {
|
||
autoSubscribe: true,
|
||
});
|
||
} catch (error) {
|
||
ElMessage.error(`连接失败: ${error.message}`);
|
||
status.value = true;
|
||
}
|
||
}
|
||
|
||
// 离开会议函数
|
||
async function leaveRoom() {
|
||
try {
|
||
// 如果正在放大视频,发送缩小消息
|
||
if (enlargedParticipant.value && enlargedParticipant.value.identity === hostUid.value) {
|
||
publishEnlargeVideoMessage(VIDEO_ENLARGE_MESSAGE_TYPES.SHRINK, {
|
||
participant: {
|
||
identity: hostUid.value
|
||
}
|
||
});
|
||
}
|
||
// 如果白板正在运行,先退出白板
|
||
if (isWhiteboardActive.value) {
|
||
publishWhiteboardMessage(WHITEBOARD_MESSAGE_TYPES.CLOSE, {
|
||
action: 'close',
|
||
reason: 'host_left',
|
||
roomName: roomName.value
|
||
});
|
||
isWhiteboardActive.value = false;
|
||
if (whiteboardRef.value && whiteboardRef.value.cleanup) {
|
||
whiteboardRef.value.cleanup();
|
||
}
|
||
}
|
||
// 断开MQTT连接
|
||
mqttClient.disconnect();
|
||
// const res = await exitRoomApi(room.name)
|
||
// 停止屏幕共享(如果正在共享)
|
||
if (isScreenSharing.value) {
|
||
await room.localParticipant.setScreenShareEnabled(false);
|
||
}
|
||
// 关闭摄像头和麦克风
|
||
await room.localParticipant.setCameraEnabled(false);
|
||
await room.localParticipant.setMicrophoneEnabled(false);
|
||
microphoneEnabled.value = false;
|
||
cameraEnabled.value = false;
|
||
selectedMicrophoneId.value = '';
|
||
selectedCameraId.value = ''
|
||
// 断开与房间的连接
|
||
await room.disconnect();
|
||
// 重置所有状态
|
||
resetRoomState();
|
||
ElMessage.success('已离开会议');
|
||
|
||
} catch (error) {
|
||
console.error('离开会议失败:', error);
|
||
ElMessage.error('离开会议失败');
|
||
}
|
||
}
|
||
|
||
async function leaveRoomHandle(){
|
||
await leaveRoom()
|
||
router.push({
|
||
path: '/coordinate',
|
||
})
|
||
}
|
||
|
||
// 重置房间状态函数
|
||
function resetRoomState() {
|
||
// 重置本地状态
|
||
globalScreenSharingUser.value = '';
|
||
isGlobalScreenSharing.value = false;
|
||
isWhiteboardActive.value = false;
|
||
cameraEnabled.value = false;
|
||
microphoneEnabled.value = false;
|
||
isScreenSharing.value = false;
|
||
isLocalSpeaking.value = false;
|
||
status.value = true;
|
||
hostUid.value = '';
|
||
selectedMicrophoneId.value = '';
|
||
selectedCameraId.value = ''
|
||
microphoneDevices.value = [];
|
||
cameraDevices.value = [];
|
||
enlargedParticipant.value = null;
|
||
|
||
// 断开MQTT连接
|
||
mqttClient.disconnect();
|
||
|
||
// 重置远程参与者
|
||
remoteParticipants.value.clear();
|
||
videoElementsMap.value.clear();
|
||
audioElementsMap.value.clear();
|
||
|
||
// 重置屏幕共享状态
|
||
screenSharingUser.value = '';
|
||
activeScreenShareTrack.value = null;
|
||
|
||
// 清理视频元素
|
||
if (localVideo.value && localVideo.value.srcObject) {
|
||
localVideo.value.srcObject.getTracks().forEach(track => track.stop());
|
||
localVideo.value.srcObject = null;
|
||
}
|
||
|
||
// 清理音频元素
|
||
if (localAudio.value && localAudio.value.srcObject) {
|
||
localAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
||
localAudio.value.srcObject = null;
|
||
}
|
||
|
||
if (screenShareVideo.value && screenShareVideo.value.srcObject) {
|
||
screenShareVideo.value.srcObject.getTracks().forEach(track => track.stop());
|
||
screenShareVideo.value.srcObject = null;
|
||
}
|
||
|
||
// 清理所有远程参与者的音视频元素
|
||
videoElementsMap.value.forEach((elements, identity) => {
|
||
if (elements.camera && elements.camera.srcObject) {
|
||
elements.camera.srcObject.getTracks().forEach(track => track.stop());
|
||
elements.camera.srcObject = null;
|
||
}
|
||
if (elements.screen && elements.screen.srcObject) {
|
||
elements.screen.srcObject.getTracks().forEach(track => track.stop());
|
||
elements.screen.srcObject = null;
|
||
}
|
||
});
|
||
|
||
audioElementsMap.value.forEach((audioElement, identity) => {
|
||
if (audioElement && audioElement.srcObject) {
|
||
audioElement.srcObject.getTracks().forEach(track => track.stop());
|
||
audioElement.srcObject = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
watch([() => hasActiveScreenShare.value, () => isScreenSharing.value], ([newHasActiveScreenShare, newIsScreenSharing]) => {
|
||
// 如果屏幕共享被关闭(包括本地停止共享或远程共享结束)
|
||
if (!newHasActiveScreenShare && !newIsScreenSharing) {
|
||
// 检查激光笔是否处于开启状态
|
||
if (isLaserPointerActive.value) {
|
||
// console.log('屏幕共享已结束,自动关闭激光笔');
|
||
// 自动关闭激光笔
|
||
closeLaserPointer();
|
||
}
|
||
}
|
||
}, { immediate: true });
|
||
|
||
// 在摄像头关闭时,如果正在放大自己的视频,自动关闭放大视图
|
||
watch(cameraEnabled, (newVal) => {
|
||
if (!newVal) {
|
||
// 摄像头关闭时,如果正在放大自己的视频,自动关闭
|
||
if (enlargedParticipant.value && enlargedParticipant.value.identity === hostUid.value) {
|
||
closeEnlargedView();
|
||
}
|
||
}
|
||
});
|
||
|
||
watch([hasActiveScreenShare, enlargedParticipant], ([newHasActiveScreenShare, newEnlargedParticipant]) => {
|
||
if (isLaserPointerActive.value) {
|
||
// 延迟执行,确保 DOM 已更新
|
||
nextTick(() => {
|
||
initLaserPointerCanvas();
|
||
});
|
||
}
|
||
});
|
||
// 在激光笔激活时初始化 Canvas
|
||
watch(isLaserPointerActive, (newVal) => {
|
||
if (newVal) {
|
||
nextTick(() => {
|
||
if (enlargedParticipant.value) {
|
||
initEnlargedLaserPointerCanvas();
|
||
} else {
|
||
initLaserPointerCanvas();
|
||
}
|
||
});
|
||
} else {
|
||
// 清理观察器
|
||
if (canvasSizeObserver.value) {
|
||
canvasSizeObserver.value.disconnect();
|
||
canvasSizeObserver.value = null;
|
||
}
|
||
}
|
||
});
|
||
|
||
// 监听放大视频状态变化,初始化激光笔
|
||
watch(enlargedParticipant, (newVal) => {
|
||
if (newVal && isLaserPointerActive.value) {
|
||
nextTick(() => {
|
||
initEnlargedLaserPointerCanvas();
|
||
});
|
||
} else {
|
||
cleanupEnlargedLaserPointer();
|
||
}
|
||
});
|
||
// 添加专门的关闭激光笔函数
|
||
function closeLaserPointer() {
|
||
isLaserPointerActive.value = false;
|
||
cleanupLaserPointer();
|
||
|
||
// 重置Canvas样式
|
||
if (laserPointerCanvas.value) {
|
||
laserPointerCanvas.value.style.pointerEvents = 'none';
|
||
laserPointerCanvas.value.style.cursor = 'default';
|
||
}
|
||
|
||
ElMessage.info('屏幕共享已结束,激光笔已自动关闭');
|
||
}
|
||
|
||
// 在组件卸载时也清理资源
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', resizeLaserPointerCanvas);
|
||
window.removeEventListener('resize', resizeEnlargedLaserPointerCanvas);
|
||
if (canvasSizeObserver.value) {
|
||
canvasSizeObserver.value.disconnect();
|
||
}
|
||
cleanupAllLaserElements();
|
||
if (isWhiteboardActive.value && whiteboardRef.value && whiteboardRef.value.cleanup) {
|
||
whiteboardRef.value.cleanup();
|
||
}
|
||
mqttClient.disconnect();
|
||
if (room && room.state === 'connected') {
|
||
leaveRoom();
|
||
}
|
||
});
|
||
onMounted(async () => {
|
||
if(route.query.type == '1'){
|
||
await joinRoomBtn()
|
||
hostUid.value = roomStore.userUid
|
||
// 邀请用户参与房间
|
||
if(room.name){
|
||
await getInvite(room.name,{user_uids:[roomStore.detailUid], participant_role: "participant"})
|
||
}
|
||
} else {
|
||
const res = await getTokenApi(route.query.room_uid)
|
||
if(res.meta.code == 200){
|
||
const token = res.data.access_token;
|
||
hostUid.value = res.data.user_uid
|
||
await nextTick();
|
||
setupRoomListeners();
|
||
await room.connect(wsURL, token, {
|
||
autoSubscribe: true,
|
||
});
|
||
}else{
|
||
ElMessage.error(res.meta.message);
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.enlarged-laser-canvas {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 10;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.enlarged-laser-canvas.active {
|
||
pointer-events: auto;
|
||
cursor: crosshair;
|
||
}
|
||
|
||
.enlarged-video-container {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.enlarged-video-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.enlarged-video-element {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.audio-on {
|
||
color: #67c23a; /* 绿色表示在线 */
|
||
}
|
||
|
||
.audio-off {
|
||
color: #f56c6c; /* 红色表示离线 */
|
||
}
|
||
|
||
.status-indicator .el-icon {
|
||
margin-right: 4px;
|
||
font-size: 18px;
|
||
}
|
||
|
||
/* 参与者操作按钮 */
|
||
.participant-actions {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.enlarge-btn {
|
||
padding: 4px;
|
||
color: #a0a0b0;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.enlarge-btn:hover {
|
||
color: #409eff;
|
||
background: rgba(64, 158, 255, 0.1);
|
||
}
|
||
|
||
.close-enlarge-btn {
|
||
margin-left: auto;
|
||
}
|
||
|
||
/* 放大视频容器样式 */
|
||
.enlarged-video-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.enlarged-video-wrapper {
|
||
width: 100%;
|
||
height: 100%;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
position: relative;
|
||
}
|
||
|
||
// .enlarged-video-element {
|
||
// width: 100%;
|
||
// height: 100%;
|
||
// object-fit: contain;
|
||
// background: #000;
|
||
// border-radius: 8px;
|
||
// }
|
||
|
||
/* 激光笔 Canvas 样式 */
|
||
.laser-pointer-canvas {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: 10;
|
||
pointer-events: auto;
|
||
cursor: crosshair;
|
||
}
|
||
/* 激光笔状态指示器 */
|
||
.laser-pointer-indicator {
|
||
color: #ff0000;
|
||
font-size: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-left: 12px;
|
||
}
|
||
.laser-pointer-indicator i {
|
||
font-size: 14px;
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
100% { opacity: 1; }
|
||
}
|
||
/* 屏幕共享包装器需要相对定位 */
|
||
.screen-share-wrapper {
|
||
position: relative;
|
||
}
|
||
/* 白板容器样式 */
|
||
.whiteboard-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
.whiteboard-component {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
/* 添加音频状态指示器样式 */
|
||
.audio-indicator {
|
||
margin-left: 8px;
|
||
font-size: 12px;
|
||
}
|
||
.audio-indicator.muted {
|
||
color: #f56c6c;
|
||
}
|
||
.audio-indicator:not(.muted) {
|
||
color: #67c23a;
|
||
}
|
||
@keyframes pulse {
|
||
0% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
100% { opacity: 1; }
|
||
}
|
||
/* 隐藏音频元素,不显示在页面上 */
|
||
.audio-element, .participant-audio {
|
||
display: none;
|
||
}
|
||
/* 控制下拉菜单样式 */
|
||
.control-dropdown {
|
||
margin: 0;
|
||
}
|
||
.control-dropdown .el-button {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
/* 选中的设备样式 */
|
||
.selected-device {
|
||
background-color: #f0f9ff;
|
||
color: #409eff;
|
||
font-weight: 500;
|
||
}
|
||
.check-icon {
|
||
margin-left: auto;
|
||
color: #67c23a;
|
||
}
|
||
/* 全局样式 */
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: #0f0f1a;
|
||
color: #e0e0e0;
|
||
}
|
||
.livekit-container {
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 100%);
|
||
}
|
||
/* 会议界面样式 */
|
||
.meeting-container {
|
||
width: calc(100vw - 42px) ;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
/* 视频布局 */
|
||
.video-layout {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
height: calc(100vh - 80px);
|
||
}
|
||
/* 当有屏幕共享时的布局 */
|
||
.video-layout.screen-sharing-active {
|
||
flex-direction: row;
|
||
gap: 20px;
|
||
}
|
||
/* 屏幕共享区域 */
|
||
.screen-share-area {
|
||
flex: 3;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.screen-share-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
.screen-share-header h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
}
|
||
.sharing-user {
|
||
color: #a0a0b0;
|
||
font-size: 14px;
|
||
}
|
||
.screen-share-video {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
.screen-share-wrapper {
|
||
width: 100%;
|
||
height: calc(100% - 36px) ;
|
||
max-height: 100%;
|
||
}
|
||
.screen-share-element {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
background: #000;
|
||
border-radius: 8px;
|
||
}
|
||
/* 参会者区域 */
|
||
.participants-area {
|
||
flex: 1;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.participants-area.with-screen-share {
|
||
max-width: 400px;
|
||
}
|
||
.participants-header {
|
||
padding: 16px 20px;
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
.participants-header h3 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
}
|
||
.video-grid {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
padding: 16px;
|
||
overflow-y: auto;
|
||
max-height: calc(100vh - 200px); /* 为底部控制栏留出空间 */
|
||
margin-bottom: 80px; /* 确保内容不被底部控制栏遮挡 */
|
||
}
|
||
.video-grid.grid-layout {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: 16px;
|
||
align-items: start;
|
||
}
|
||
.participant-card {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
backdrop-filter: blur(10px);
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
overflow: hidden;
|
||
transition: all 0.3s ease;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 280px; /* 统一最小高度 */
|
||
}
|
||
.video-grid.grid-layout .video-wrapper {
|
||
height: 200px; /* 在网格布局中也保持相同高度 */
|
||
}
|
||
.participant-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
|
||
}
|
||
.local-participant {
|
||
border: 2px solid rgba(74, 108, 247, 0.5);
|
||
}
|
||
.participant-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background: rgba(0, 0, 0, 0.2);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||
}
|
||
.participant-header h3 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #ffffff;
|
||
}
|
||
.status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: #a0a0b0;
|
||
}
|
||
.video-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
height:calc(100% - 36px) ; /* 固定高度 */
|
||
overflow: hidden;
|
||
background: #000; /* 统一背景色 */
|
||
border-radius: 8px;
|
||
}
|
||
.video-element {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
background: #000;
|
||
}
|
||
.video-overlay {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
padding: 8px 12px;
|
||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||
color: white;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
z-index: 2;
|
||
}
|
||
.participant-name {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
.video-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(135deg, #2a2a3a 0%, #1a1a2a 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #a0a0b0;
|
||
font-size: 14px;
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
}
|
||
.video-placeholder i {
|
||
font-size: 32px;
|
||
margin-bottom: 8px;
|
||
color: #4a4a5a;
|
||
}
|
||
.fixed-controls {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: rgba(15, 15, 26, 0.95);
|
||
backdrop-filter: blur(20px);
|
||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||
padding: 16px 20px;
|
||
z-index: 1000;
|
||
}
|
||
.controls-container {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 16px;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
.control-btn {
|
||
border-radius: 12px;
|
||
border: none;
|
||
font-weight: 600;
|
||
transition: all 0.3s ease;
|
||
padding: 12px 24px;
|
||
font-size: 14px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
}
|
||
.control-btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||
}
|
||
.control-btn i {
|
||
margin-right: 8px;
|
||
font-size: 16px;
|
||
}
|
||
.dropdown-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.selected-device {
|
||
background-color: #f0f7ff;
|
||
color: #409eff;
|
||
}
|
||
.check-icon {
|
||
margin-left: 8px;
|
||
color: #67c23a;
|
||
}
|
||
.microphone-control-group {
|
||
display: flex;
|
||
align-items: center;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
.microphone-control-group .microphone-btn {
|
||
border-radius: 12px 0 0 12px;
|
||
border: none;
|
||
box-shadow: none;
|
||
margin: 0;
|
||
}
|
||
.microphone-control-group .microphone-dropdown {
|
||
margin: 0;
|
||
}
|
||
.microphone-control-group .dropdown-btn {
|
||
border-radius: 0 12px 12px 0;
|
||
border: none;
|
||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||
box-shadow: none;
|
||
width: 44px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
/* 悬停效果 */
|
||
.microphone-control-group:hover {
|
||
border-color: rgba(255, 255, 255, 0.2);
|
||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||
}
|
||
.microphone-control-group .control-btn:hover {
|
||
transform: none; /* 取消单独的悬停位移 */
|
||
}
|
||
/* 响应式调整 */
|
||
@media (max-width: 768px) {
|
||
.microphone-control-group {
|
||
flex: 1;
|
||
min-width: 160px;
|
||
}
|
||
.microphone-control-group .microphone-btn {
|
||
flex: 1;
|
||
}
|
||
.microphone-control-group .dropdown-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
}
|
||
.whiteboard-container {
|
||
height: 300px; /* 在移动端限制白板高度 */
|
||
}
|
||
.video-layout.screen-sharing-active {
|
||
flex-direction: column;
|
||
}
|
||
.participants-area.with-screen-share {
|
||
max-width: 100%;
|
||
}
|
||
/* 在小屏幕上使用单列布局 */
|
||
.video-grid.grid-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
/* 移动端控制栏调整 */
|
||
.fixed-controls {
|
||
padding: 12px 16px;
|
||
}
|
||
.controls-container {
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.control-btn {
|
||
padding: 10px 16px;
|
||
font-size: 12px;
|
||
flex: 1;
|
||
min-width: 120px;
|
||
}
|
||
.control-btn i {
|
||
margin-right: 6px;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
/* 中等屏幕上的网格布局 */
|
||
@media (min-width: 769px) and (max-width: 1200px) {
|
||
.video-grid.grid-layout {
|
||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||
}
|
||
}
|
||
/* 大屏幕上的网格布局 */
|
||
@media (min-width: 1201px) {
|
||
.video-grid.grid-layout {
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
}
|
||
}
|
||
/* 超小屏幕调整 */
|
||
@media (max-width: 480px) {
|
||
.microphone-control-group {
|
||
min-width: 140px;
|
||
}
|
||
.microphone-control-group .microphone-btn {
|
||
font-size: 11px;
|
||
padding: 8px 10px;
|
||
}
|
||
.microphone-control-group .dropdown-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
}
|
||
.controls-container {
|
||
gap: 6px;
|
||
}
|
||
.control-btn {
|
||
padding: 8px 12px;
|
||
font-size: 11px;
|
||
min-width: 100px;
|
||
}
|
||
.control-btn i {
|
||
margin-right: 4px;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
</style> |