1565 lines
49 KiB
Vue
1565 lines
49 KiB
Vue
<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> |