Files
xSynergy-manage/src/views/conferencingRoom/pathTransit.vue

3175 lines
110 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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"
@click="closeEnlargedView"
type="info"
size="small"
class="close-enlarge-btn"
>
关闭放大
</el-button>
</div>
<div class="screen-share-video">
<!-- 放大视频模式 -->
<div v-if="enlargedParticipant" class="enlarged-video-container">
<div class="video-wrapper enlarged-video-wrapper" style='border:1px solid red'>
<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>
</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 && !enlargedParticipant">
<el-button
@click="enlargeParticipant({ identity: hostUid, hasCameraTrack: cameraEnabled, audioEnabled: microphoneEnabled })"
type="text"
size="small"
class="enlarge-btn"
title="放大视图"
>
<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 && !enlargedParticipant">
<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 && !participant.hasScreenTrack" class="video-placeholder">
<i class="el-icon-user"></i>
<span>暂无视频流</span>
</div>
</div>
<!-- video-overlay -->
<div class="video-tracks">
<span class="participant-name">{{ participant.identity }}</span>
<div v-if="participant.hasScreenTrack" class="video-placeholder">
<i class="el-icon-user"></i>
<span>暂无视频流</span>
</div>
</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="cameraEnabled || (isGlobalScreenSharing && !isScreenSharing)"
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"
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 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); // 放大视频元素引用
// 路径更新节流处理
let lastPublishTime = 0;
const publishThrottleTime = 100; // 100ms 节流
// 鼠标状态跟踪
const mouseState = reactive({
isDrawing: false,
lastX: 0,
lastY: 0,
startX: 0,
startY: 0,
currentElementId: null // 当前正在绘制的元素ID
});
// 激光笔样式配置
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;
});
// 添加一个计算属性来显示屏幕共享状态提示
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 (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
}
});
}
// 关闭放大视图
function closeEnlargedView() {
// 如果当前放大的是自己的视频,发送缩小消息
if (enlargedParticipant.value && enlargedParticipant.value.identity === hostUid.value) {
publishEnlargeVideoMessage(VIDEO_ENLARGE_MESSAGE_TYPES.SHRINK, {
participant: {
identity: hostUid.value
}
});
}
enlargedParticipant.value = null;
ElMessage.info('已关闭放大视图');
}
// 设置放大视频引用
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('放大视频已加载');
}
// 监听摄像头状态变化,如果摄像头开启则禁止共享屏幕和白板
watch(cameraEnabled, (newVal) => {
if (newVal) {
// 摄像头开启时,如果正在共享屏幕或白板,自动关闭
if (isScreenSharing.value) {
room.localParticipant.setScreenShareEnabled(false);
isScreenSharing.value = false;
ElMessage.warning('摄像头已开启,自动停止屏幕共享');
}
if (isWhiteboardActive.value) {
exitWhiteboard();
ElMessage.warning('摄像头已开启,自动退出白板');
}
}
});
//订阅视屏缩放
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);
console.log('data视屏缩放的mqtt消息',data)
// 只处理当前房间的消息
if (data.roomId !== room.name) return;
// 忽略自己发送的消息
if (data.sender === hostUid.value) return;
console.log(data.type,'data.type')
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 (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);
}
});
}
// 处理远程视频缩小
function handleRemoteVideoShrink(data) {
const { participant } = data.payload;
// 如果当前放大的正是这个用户,关闭放大视图
if (enlargedParticipant.value && enlargedParticipant.value.identity === participant.identity) {
enlargedParticipant.value = null;
ElMessage.info(`${participant.identity} 关闭了放大视图`);
}
}
//发送视屏缩放消息
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;
}
if (!hasActiveScreenShare.value && !isWhiteboardActive.value && !enlargedParticipant.value) {
ElMessage.warning('请在屏幕共享、白板或放大视图模式下使用激光笔');
return;
}
isLaserPointerActive.value = !isLaserPointerActive.value;
if (isLaserPointerActive.value) {
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);
}
mouseState.isDrawing = false;
mouseState.currentPath = [];
mouseState.currentElementId = null;
}
// 添加新的激光笔元素
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();
return elementId;
}
// 移除单个激光笔元素
function removeLaserElement(elementId) {
// 清除定时器
if (laserPointerTimeouts.value.has(elementId)) {
clearTimeout(laserPointerTimeouts.value.get(elementId));
laserPointerTimeouts.value.delete(elementId);
}
// 移除元素
laserPointerElements.value.delete(elementId);
// 重新绘制
redrawLaserElements();
}
function handleScreenShareLoaded() {
// 视频加载完成后初始化激光笔 Canvas
initLaserPointerCanvas();
}
// 初始化激光笔 Canvas
function initLaserPointerCanvas() {
if (!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) return;
const rect = targetElement.getBoundingClientRect();
// 设置 Canvas 尺寸
laserPointerCanvas.value.width = rect.width;
laserPointerCanvas.value.height = rect.height;
// 获取上下文
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 = 'crosshair';
laserPointerCanvas.value.style.pointerEvents = isLaserPointerActive.value ? 'auto' : 'none';
}
// 清理激光笔
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 pixelCoords = getPixelCoordinates(e);
// 创建圆形标记元素
const circleElement = {
type: 'circle',
data: {
color: laserPointerConfig.color,
start: coords,
thickness: laserPointerConfig.thickness,
pixelCoords: pixelCoords
}
};
// 添加元素并获取ID
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;
mouseState.lastX = pixelCoords.x;
mouseState.lastY = pixelCoords.y;
mouseState.currentPath = [getMouseCoordinates(e)];
// 创建新的路径元素
const pathElement = {
type: 'path',
data: {
color: laserPointerConfig.color,
thickness: laserPointerConfig.thickness,
path: [...mouseState.currentPath]
}
};
// 添加元素并记录ID
mouseState.currentElementId = addLaserElement(pathElement);
}
function handleCanvasMouseMove(e) {
if (!isLaserPointerActive.value || !mouseState.isDrawing) return;
const pixelCoords = getPixelCoordinates(e);
const percentageCoords = getMouseCoordinates(e);
// 更新当前路径
mouseState.currentPath.push(percentageCoords);
// 更新对应的元素
if (mouseState.currentElementId && laserPointerElements.value.has(mouseState.currentElementId)) {
const element = laserPointerElements.value.get(mouseState.currentElementId);
element.data.path = [...mouseState.currentPath];
laserPointerElements.value.set(mouseState.currentElementId, element);
// 重新绘制
redrawLaserElements();
// 发送实时更新到其他用户(节流处理)
throttlePublishPathUpdate(element);
}
mouseState.lastX = pixelCoords.x;
mouseState.lastY = pixelCoords.y;
}
function throttlePublishPathUpdate(element) {
const now = Date.now();
if (now - lastPublishTime > publishThrottleTime) {
publishLaserPointerData(element);
lastPublishTime = now;
}
}
function handleCanvasMouseUp(e) {
if (!isLaserPointerActive.value || !mouseState.isDrawing) return;
const endCoords = getMouseCoordinates(e);
// 完成路径绘制
if (mouseState.currentElementId && laserPointerElements.value.has(mouseState.currentElementId)) {
const element = laserPointerElements.value.get(mouseState.currentElementId);
// 发送最终路径
publishLaserPointerData(element);
}
// 如果是快速点击,创建短线
if (mouseState.currentPath.length <= 2) {
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
});
}
// 移除之前创建的路径元素
if (mouseState.currentElementId) {
removeLaserElement(mouseState.currentElementId);
}
}
mouseState.isDrawing = false;
mouseState.currentElementId = null;
}
function handleCanvasMouseLeave() {
if (mouseState.isDrawing && mouseState.currentElementId) {
// 发送最终的路径数据
const element = laserPointerElements.value.get(mouseState.currentElementId);
if (element) {
publishLaserPointerData(element);
}
}
mouseState.isDrawing = false;
mouseState.currentElementId = null;
}
// 绘制函数
function drawCircle(coords) {
if (!laserPointerContext.value) return;
const ctx = laserPointerContext.value;
ctx.beginPath();
ctx.arc(coords.x, coords.y, 2, 0, Math.PI * 2);
ctx.fillStyle = laserPointerConfig.color;
ctx.fill();
// ctx.strokeStyle = '#ffffff';
// ctx.lineWidth = 2;
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 drawPencilPath(pencilData) {
if (!laserPointerContext.value || !pencilData.path || pencilData.path.length < 2) return;
const ctx = laserPointerContext.value;
// 将百分比坐标转换为像素坐标
const pixelPath = pencilData.path.map(pt => ({
x: parseFloat(pt.x) * laserPointerCanvas.value.width,
y: parseFloat(pt.y) * laserPointerCanvas.value.height
}));
// 设置绘制样式
ctx.strokeStyle = pencilData.color;
ctx.lineWidth = pencilData.thickness;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 绘制平滑曲线
drawSmoothCurve(ctx, pixelPath);
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' && element.data.pixelCoords) {
const start = getPixelFromPercentage(element.data.start)
drawCircle(start);
} 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);
} else if (element.type === 'path' && element.data.path) {
drawPencilPath(element.data);
}
});
}
// 从百分比坐标转换为像素坐标
function getPixelFromPercentage(percentageCoords) {
if (!laserPointerCanvas.value) return { x: 0, y: 0 };
return {
x: percentageCoords.x * laserPointerCanvas.value.width,
y: 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-wrapper');
} else if (enlargedParticipant.value) {
targetElement = document.querySelector('.enlarged-video-wrapper');
}
if (!targetElement) return;
const rect = targetElement.getBoundingClientRect();
laserPointerCanvas.value.width = rect.width;
laserPointerCanvas.value.height = rect.height;
// 重新绘制所有元素
redrawLaserElements();
}
// 监听窗口大小变化
window.addEventListener('resize', resizeLaserPointerCanvas);
// 在初始化 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();
}
//文件上传
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('已成功连接到房间');
// 初始化现有远程参与者
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) {
// 更新参与者轨道信息
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 });
}
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;
} 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.setMicrophoneEnabled(true);
microphoneEnabled.value = true;
// 手动获取并附加视频轨道
setTimeout(() => {
attachLocalCameraTrack();
}, 200);
ElMessage.success('麦克风已开启');
}
ElMessage.success('摄像头已开启');
}
} catch (error) {
errorHandling(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(cameraEnabled.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();
}
}
});
// 添加专门的关闭激光笔函数
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);
cleanupAllLaserElements();
if (isWhiteboardActive.value && whiteboardRef.value && whiteboardRef.value.cleanup) {
whiteboardRef.value.cleanup();
}
// 清理MQTT
mqttClient.disconnect();
if (room && room.state === 'connected') {
leaveRoom();
}
});
onMounted(async () => {
if(route.query.type == '1'){
await joinRoomBtn()
hostUid.value = roomStore.userUid
// 邀请用户参与房间
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>
.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>