Files
xSynergy-manage/src/views/conferencingRoom/index.vue
2025-09-30 17:58:47 +08:00

1565 lines
49 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>
<!-- 音频元素 -->
<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 }">
<!-- 左侧共享屏幕区域 -->
<div class="screen-share-area" v-if="hasActiveScreenShare">
<div class="screen-share-header">
<h3>共享屏幕</h3>
<div class="sharing-user">
{{ screenSharingUser }} 共享
</div>
</div>
<div class="screen-share-video">
<div class="video-wrapper screen-share-wrapper">
<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>
</div>
</div>
</div>
</div>
<!-- 右侧视频区域 -->
<div class="participants-area" :class="{ 'with-screen-share': hasActiveScreenShare }">
<div class="participants-header">
<h3>会议名称测试会议名称</h3>
<h3>参会者 ({{ participantCount }})</h3>
</div>
<div class="video-grid" :class="{ 'grid-layout': !hasActiveScreenShare && participantCount > 1 }">
<!-- 本地视频 -->
<div class="participant-card local-participant">
<div class="participant-header">
<h3>我的视频 ({{ hostUid }})</h3>
<div class="status-indicator">
<span class="status-dot" :class="{ 'speaking': isLocalSpeaking }"></span>
<span>{{ microphoneEnabled ? '音频在线' : '音频离线' }}</span>
</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">
<div class="participant-header">
<h3>{{ participant.identity }}</h3>
<div class="status-indicator">
<span class="status-dot" :class="{ 'speaking': participant.isSpeaking }"></span>
<span>{{ participant.audioEnabled ? '音频在线' : '音频离线' }}</span>
</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>
<div class="video-overlay">
<span class="participant-name">{{ participant.identity }}</span>
<span v-if="participant.hasScreenTrack" class="screen-sharing-indicator">
<i class="el-icon-monitor"></i> 正在共享
</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">
</audio>
</div>
</div>
</div>
</div>
<!-- 固定在底部的控制按钮 -->
<div class="fixed-controls">
<div class="controls-container">
<el-button @click="toggleCamera" :type="cameraEnabled ? 'danger' : 'success'" class="control-btn" size="large">
<i :class="cameraEnabled ? 'el-icon-video-camera' : 'el-icon-video-camera-solid'"></i>
{{ cameraEnabled ? '关闭摄像头' : '开启摄像头' }}
</el-button>
<el-button @click="toggleMicrophone" :type="microphoneEnabled ? 'danger' : 'success'" class="control-btn" size="large">
<i :class="microphoneEnabled ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'"></i>
{{ microphoneEnabled ? '关闭麦克风' : '开启麦克风' }}
</el-button>
<el-button @click="toggleScreenShare" :type="isScreenSharing ? 'danger' : 'primary'" class="control-btn" size="large">
<i class="el-icon-monitor"></i>
{{ isScreenSharing ? '停止共享' : '共享屏幕' }}
</el-button>
<el-button @click="leaveRoom" type="warning" class="control-btn leave-btn" size="large">
<i class="el-icon-switch-button"></i>
离开会议
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted, onUnmounted, nextTick, computed, watch } from "vue";
import { ElMessage } from 'element-plus';
import { getRoomToken, getRoomList ,getInvite,getTokenApi,exitRoomApi} from "@/api/conferencingRoom.js"
import { Room, RoomEvent, ParticipantEvent, Track } from "livekit-client";
import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '@/stores/modules/room.js'
import useUserStore from '@/stores/modules/user'
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 localVideo = ref(null);
const localAudio = ref(null);
const cameraEnabled = ref(false);
const microphoneEnabled = ref(false);
const isScreenSharing = ref(false);
const isLocalSpeaking = ref(false);
// 远程参与者管理
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 participantCount = computed(() => {
return remoteParticipants.value.size + 1; // 包括自己
});
const remoteParticipantsArray = computed(() => {
return Array.from(remoteParticipants.value.values());
});
const hasActiveScreenShare = computed(() => {
return screenSharingUser.value !== '' || isScreenSharing.value;
});
// 创建 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, // 64kbps for audio
},
dtx: true, // 不连续传输,节省带宽
red: true, // 冗余编码,提高抗丢包能力
}
});
// 设置视频元素引用
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);
}
}
// 设置事件监听器
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() {
console.log("成功连接到房间:", room.name);
status.value = false;
ElMessage.success('已成功连接到房间');
// 初始化现有远程参与者
room.remoteParticipants.forEach(participant => {
addRemoteParticipant(participant);
setupParticipantListeners(participant);
// 立即检查并更新参与者的轨道状态
updateParticipantTracks(participant);
});
// 自动开启麦克风
try {
await enableMicrophone();
ElMessage.success('麦克风已自动开启');
} catch (error) {
console.warn('自动开启麦克风失败:', error);
}
}
function handleDisconnected(reason) {
console.log("断开连接:", 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;
ElMessage.error('连接已断开');
}
function handleReconnected() {
console.log("已重新连接");
ElMessage.success('已重新连接到房间');
}
// 处理轨道订阅事件
function handleTrackSubscribed(track, publication, participant) {
console.log("轨道已订阅:", track.kind, "轨道来源:", publication.source, "来自:", participant.identity);
if (track) {
if (track.kind === Track.Kind.Video) {
// 更新参与者轨道信息
updateParticipantTrack(participant, publication.source, track);
// 如果是屏幕共享,更新屏幕共享状态
if (publication.source === Track.Source.ScreenShare) {
updateScreenShareState(participant, track);
}
// 立即附加到视频元素(如果元素已存在)
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);
}
} 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;
}
}
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;
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) {
screenSharingUser.value = '';
activeScreenShareTrack.value = null;
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) => {
handleTrackSubscribed(track, publication, participant);
})
.on(ParticipantEvent.TrackUnsubscribed, (track, publication) => {
handleTrackUnsubscribed(track, publication, participant);
})
.on(ParticipantEvent.TrackMuted, (publication) => {
handleTrackMuted(publication, participant);
})
.on(ParticipantEvent.TrackUnmuted, (publication) => {
handleTrackUnmuted(publication, participant);
})
.on(ParticipantEvent.IsSpeakingChanged, (speaking) => {
updateParticipantSpeaking(participant, speaking);
});
}
// 处理活动说话者变化事件
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 handleDataReceived(payload, participant, kind) {
try {
const decoder = new TextDecoder();
const strData = decoder.decode(payload);
ElMessage.info(`收到消息 from ${participant.identity}: ${strData}`);
} catch (error) {
console.error('处理接收消息失败:', error);
}
}
function handleConnectionStateChanged(state) {
console.log('连接状态改变:', state);
}
// 更新屏幕共享状态
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) {
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) {
hasAudio = true;
if (!data.audioTrack) {
data.audioTrack = publication.track;
}
}
});
data.hasCameraTrack = hasCamera;
data.hasScreenTrack = hasScreen;
data.audioEnabled = hasAudio;
data.videoEnabled = participant.isCameraEnabled;
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 handleVideoLoaded(identity, type) {
console.log(`视频加载完成: ${identity}${type}视频`);
}
function handleScreenShareLoaded() {
console.log('屏幕共享视频加载完成');
}
// 视频轨道处理函数
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;
}
ElMessage.info('摄像头已关闭');
} else {
// 确保视频元素存在
if (!localVideo.value) {
console.warn('本地视频元素未找到等待DOM更新');
await nextTick();
}
// 开启摄像头
await room.localParticipant.setCameraEnabled(true);
cameraEnabled.value = true;
ElMessage.success('摄像头已开启');
// 手动获取并附加视频轨道,增加延迟
setTimeout(() => {
attachLocalCameraTrack();
}, 200);
}
} catch (error) {
console.error('切换摄像头失败:', error);
ElMessage.error('切换摄像头失败: ' + error.message);
}
}
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;
ElMessage.info('麦克风已关闭');
} else {
// 确保有音频输入设备权限
try {
// 先检查麦克风权限
await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (error) {
ElMessage.error('无法访问麦克风,请检查权限设置');
return;
}
await room.localParticipant.setMicrophoneEnabled(true);
microphoneEnabled.value = true;
ElMessage.success('麦克风已开启');
// 等待音频轨道发布
setTimeout(() => {
const audioPublications = Array.from(room.localParticipant.audioTrackPublications.values());
const audioPublication = audioPublications.find(pub => pub.track);
if (audioPublication && audioPublication.track) {
console.log('本地音频轨道已发布:', audioPublication.track);
}
}, 500);
}
} catch (error) {
console.error('切换麦克风失败:', error);
ElMessage.error('切换麦克风失败: ' + error.message);
}
}
async function toggleScreenShare() {
try {
if (isScreenSharing.value) {
await room.localParticipant.setScreenShareEnabled(false);
isScreenSharing.value = false;
ElMessage.info('屏幕共享已停止');
} else {
await room.localParticipant.setScreenShareEnabled(true);
isScreenSharing.value = true;
ElMessage.success('屏幕共享已开始');
}
} catch (error) {
console.error('切换屏幕共享失败:', error);
ElMessage.error('切换屏幕共享失败: ' + error.message);
}
}
async function joinRoomBtn() {
try {
const res = await getRoomToken({max_participants: 20});
const token = res.data.access_token;
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 {
const res = await exitRoomApi(room.name)
console.log(res,'离开房间成功')
// 停止屏幕共享(如果正在共享)
if (isScreenSharing.value) {
await room.localParticipant.setScreenShareEnabled(false);
}
// 关闭摄像头和麦克风
await room.localParticipant.setCameraEnabled(false);
await room.localParticipant.setMicrophoneEnabled(false);
// 断开与房间的连接
await room.disconnect();
// 重置所有状态
resetRoomState();
ElMessage.success('已离开会议');
router.push({
path: '/coordinate',
})
} catch (error) {
console.error('离开会议失败:', error);
ElMessage.error('离开会议失败: ' + error.message);
}
}
// 重置房间状态函数
function resetRoomState() {
// 重置本地状态
cameraEnabled.value = false;
microphoneEnabled.value = false;
isScreenSharing.value = false;
isLocalSpeaking.value = false;
status.value = true;
hostUid.value = '';
// 重置远程参与者
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;
}
});
}
// 在组件卸载时也清理资源
onUnmounted(() => {
if (room && room.state === 'connected') {
leaveRoom();
}
});
onMounted(async () => {
// 确保在连接前请求音频权限
try {
// 预请求音频权限
await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('音频权限已获取');
} catch (error) {
console.warn('音频权限获取失败:', error);
ElMessage.warning('请允许麦克风权限以使用音频功能');
}
if(route.query.type == '1'){
await joinRoomBtn()
hostUid.value = roomStore.userUid
// 邀请用户参与房间
await getInvite(room.name,{user_uids:[roomStore.detailUid], participant_role: "participant"})
} else {
const userInfo = await userStore.getInfo()
hostUid.value = userInfo.uid
// 确保 hostUid 更新到模板
await nextTick();
const res = await getTokenApi(route.query.room_uid)
if(res.meta.code == 200){
const token = res.data.access_token;
setupRoomListeners();
await room.connect(wsURL, token, {
autoSubscribe: true,
});
}
}
});
</script>
<style>
/* 添加音频状态指示器样式 */
.audio-indicator {
margin-left: 8px;
font-size: 12px;
}
.audio-indicator.muted {
color: #f56c6c;
}
.audio-indicator:not(.muted) {
color: #67c23a;
}
.status-dot.speaking {
background-color: #67c23a;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* 隐藏音频元素,不显示在页面上 */
.audio-element, .participant-audio {
display: none;
}
/* 全局样式 */
* {
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%);
}
/* 表单样式 */
.form-container {
max-width: 480px;
margin: 60px auto;
padding: 40px;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.form-header {
text-align: center;
margin-bottom: 32px;
}
.form-header h2 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
color: #ffffff;
}
.form-header p {
margin: 0;
color: #a0a0b0;
font-size: 16px;
}
.livekit-form {
width: 100%;
}
.livekit-form .el-form-item {
margin-bottom: 24px;
}
.livekit-form .el-form-item__label {
color: #c0c0d0;
font-weight: 500;
margin-bottom: 8px;
font-size: 14px;
}
.livekit-input {
width: 100%;
}
.livekit-input .el-input__inner {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
color: #e0e0e0;
padding: 12px 16px;
font-size: 16px;
transition: all 0.3s ease;
}
.livekit-input .el-input__inner:focus {
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.2);
}
.livekit-input .el-input__inner::placeholder {
color: #6a6a7a;
}
.radio-group {
display: flex;
gap: 16px;
}
.custom-radio {
margin-right: 0;
}
.custom-radio .el-radio__input.is-checked + .el-radio__label {
color: #4a6cf7;
}
.custom-radio .el-radio__inner {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.custom-radio .el-radio__inner::after {
background-color: #4a6cf7;
}
.join-button {
width: 100%;
background: linear-gradient(135deg, #4a6cf7 0%, #6a4af7 100%);
border: none;
border-radius: 10px;
padding: 14px;
font-size: 16px;
font-weight: 600;
margin-top: 8px;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(74, 108, 247, 0.3);
}
.join-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(74, 108, 247, 0.4);
}
/* 会议界面样式 */
.meeting-container {
width: calc(100vw - 40px) ;
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: 100%;
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;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #4ade80;
}
.video-wrapper {
position: relative;
width: 100%;
height: 100%; /* 固定高度 */
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;
}
.screen-sharing-indicator {
font-size: 11px;
color: #4a6cf7;
display: flex;
align-items: center;
gap: 4px;
}
.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;
}
.video-controls {
display: flex;
gap: 8px;
padding: 12px;
background: rgba(0, 0, 0, 0.2);
justify-content: center;
margin-top: 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;
}
.leave-btn {
background: linear-gradient(135deg, #f56c6c 0%, #e64a4a 100%);
border: none;
}
.leave-btn:hover {
background: linear-gradient(135deg, #e64a4a 0%, #d63030 100%);
}
/* 响应式调整 */
@media (max-width: 768px) {
.form-container {
margin: 20px auto;
padding: 24px;
}
.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) {
.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>