feat:更新协同合作

This commit is contained in:
leilei
2025-09-30 17:58:47 +08:00
parent 3ac556d7a5
commit e429e4286a
2 changed files with 253 additions and 60 deletions

View File

@@ -1,4 +1,6 @@
<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">
@@ -49,14 +51,17 @@
<div class="participant-header">
<h3>我的视频 ({{ hostUid }})</h3>
<div class="status-indicator">
<span class="status-dot"></span>
<span>在线</span>
<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">
@@ -71,8 +76,8 @@
<div class="participant-header">
<h3>{{ participant.identity }}</h3>
<div class="status-indicator">
<span class="status-dot"></span>
<span>在线</span>
<span class="status-dot" :class="{ 'speaking': participant.isSpeaking }"></span>
<span>{{ participant.audioEnabled ? '音频在线' : '音频离线' }}</span>
</div>
</div>
<div class="video-wrapper">
@@ -97,13 +102,23 @@
<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">
@@ -136,35 +151,30 @@ 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 formModel = reactive({
// room: 'thehome',
// uid: 'xtqxk',
// creatRoom: '0'
// });
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('');
@@ -188,6 +198,12 @@ const hasActiveScreenShare = computed(() => {
const room = new Room({
adaptiveStream: true,
dynacast: true,
// 音频捕获配置
audioCaptureDefaults: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
videoCaptureDefaults: {
resolution: { width: 1280, height: 720 }
},
@@ -200,6 +216,12 @@ const room = new Room({
maxBitrate: 2_500_000,
maxFramerate: 30,
},
// 音频发布配置
audioEncoding: {
maxBitrate: 64000, // 64kbps for audio
},
dtx: true, // 不连续传输,节省带宽
red: true, // 冗余编码,提高抗丢包能力
}
});
@@ -227,6 +249,19 @@ function setParticipantVideoRef(el, identity, type) {
}
}
// 设置音频元素引用
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;
@@ -274,15 +309,13 @@ async function handleConnected() {
updateParticipantTracks(participant);
});
// 自动开启摄像头(仅创建房间时)
// if (formModel.creatRoom === "0") {
// try {
// await enableCamera();
// ElMessage.success('摄像头已自动开启');
// } catch (error) {
// console.warn('自动开启摄像头失败:', error);
// }
// }
// 自动开启麦克风
try {
await enableMicrophone();
ElMessage.success('麦克风已自动开启');
} catch (error) {
console.warn('自动开启麦克风失败:', error);
}
}
function handleDisconnected(reason) {
@@ -293,6 +326,7 @@ function handleDisconnected(reason) {
isScreenSharing.value = false;
remoteParticipants.value.clear();
videoElementsMap.value.clear();
audioElementsMap.value.clear();
screenSharingUser.value = '';
activeScreenShareTrack.value = null;
@@ -308,17 +342,23 @@ function handleReconnected() {
function handleTrackSubscribed(track, publication, participant) {
console.log("轨道已订阅:", track.kind, "轨道来源:", publication.source, "来自:", participant.identity);
if (track && track.kind === Track.Kind.Video) {
// 更新参与者轨道信息
updateParticipantTrack(participant, publication.source, track);
// 如果是屏幕共享,更新屏幕共享状态
if (publication.source === Track.Source.ScreenShare) {
updateScreenShareState(participant, track);
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);
}
// 立即附加到视频元素(如果元素已存在)
attachTrackToParticipantVideo(participant.identity, publication.source, track);
}
// 更新参与者的轨道状态
@@ -336,6 +376,10 @@ function handleTrackUnsubscribed(track, publication, participant) {
if (publication.source === Track.Source.ScreenShare) {
updateScreenShareState(participant, null);
}
} else if (track.kind === Track.Kind.Audio) {
// 处理音频轨道取消订阅
removeParticipantAudioTrack(participant);
detachTrackFromParticipantAudio(participant.identity);
}
updateParticipantTracks(participant);
}
@@ -363,16 +407,22 @@ function handleParticipantDisconnected(participant) {
}
function handleLocalTrackPublished(publication) {
if (publication.kind === Track.Kind.Video && publication.track) {
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);
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('本地音频轨道已发布');
}
}
}
@@ -427,6 +477,11 @@ function setupParticipantListeners(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) {
@@ -448,7 +503,7 @@ function handleDataReceived(payload, participant, kind) {
}
function handleConnectionStateChanged(state) {
console.log('连接状态改变:', state);
}
// 更新屏幕共享状态
@@ -483,9 +538,10 @@ function addRemoteParticipant(participant) {
identity: participant.identity,
cameraTrack: null,
screenTrack: null,
audioTrack: null,
hasCameraTrack: false,
hasScreenTrack: false,
audioEnabled: participant.isMicrophoneEnabled,
audioEnabled: false,
videoEnabled: participant.isCameraEnabled,
isSpeaking: false
};
@@ -495,12 +551,14 @@ function addRemoteParticipant(participant) {
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);
}
}
@@ -531,6 +589,23 @@ function removeParticipantTrack(participant, source) {
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;
@@ -544,6 +619,22 @@ function attachTrackToVideo(videoElement, track) {
}
}
// 附加轨道到音频元素
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);
@@ -555,6 +646,14 @@ function attachTrackToParticipantVideo(identity, source, 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);
@@ -566,12 +665,22 @@ function detachTrackFromParticipantVideo(identity, source) {
}
}
// 从参与者的音频元素分离轨道
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) {
@@ -589,9 +698,20 @@ function updateParticipantTracks(participant) {
}
}
});
// 检查音频轨道
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 = participant.isMicrophoneEnabled;
data.audioEnabled = hasAudio;
data.videoEnabled = participant.isCameraEnabled;
remoteParticipants.value.set(participant.identity, { ...data });
}
@@ -639,6 +759,18 @@ async function enableCamera() {
}
}
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) {
@@ -709,9 +841,27 @@ async function toggleMicrophone() {
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);
@@ -738,12 +888,7 @@ async function toggleScreenShare() {
async function joinRoomBtn() {
try {
// if (!formModel.room.trim() || !formModel.uid.trim()) {
// ElMessage.error('请填写房间名称和用户编码');
// return;
// }
const res = await getRoomToken({max_participants: 20});
// const res = await getRoomToken(formModel);
const token = res.data.access_token;
if (!token) {
throw new Error('获取 token 失败');
@@ -761,7 +906,6 @@ async function joinRoomBtn() {
// 离开会议函数
async function leaveRoom() {
try {
const res = await exitRoomApi(room.name)
console.log(res,'离开房间成功')
// 停止屏幕共享(如果正在共享)
@@ -796,12 +940,14 @@ 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 = '';
@@ -813,12 +959,18 @@ function resetRoomState() {
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());
@@ -829,12 +981,15 @@ function resetRoomState() {
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') {
@@ -843,6 +998,16 @@ onUnmounted(() => {
});
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
@@ -860,13 +1025,41 @@ onMounted(async () => {
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;