feat:更新协同合作
This commit is contained in:
@@ -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,12 +102,22 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 固定在底部的控制按钮 -->
|
||||
<div class="fixed-controls">
|
||||
<div class="controls-container">
|
||||
@@ -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,7 +342,8 @@ function handleReconnected() {
|
||||
function handleTrackSubscribed(track, publication, participant) {
|
||||
console.log("轨道已订阅:", track.kind, "轨道来源:", publication.source, "来自:", participant.identity);
|
||||
|
||||
if (track && track.kind === Track.Kind.Video) {
|
||||
if (track) {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
// 更新参与者轨道信息
|
||||
updateParticipantTrack(participant, publication.source, track);
|
||||
|
||||
@@ -319,6 +354,11 @@ function handleTrackSubscribed(track, publication, participant) {
|
||||
|
||||
// 立即附加到视频元素(如果元素已存在)
|
||||
attachTrackToParticipantVideo(participant.identity, publication.source, track);
|
||||
} else if (track.kind === Track.Kind.Audio) {
|
||||
// 处理音频轨道订阅
|
||||
updateParticipantAudioTrack(participant, track);
|
||||
attachTrackToParticipantAudio(participant.identity, 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,7 +407,8 @@ function handleParticipantDisconnected(participant) {
|
||||
}
|
||||
|
||||
function handleLocalTrackPublished(publication) {
|
||||
if (publication.kind === Track.Kind.Video && 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) {
|
||||
@@ -374,6 +419,11 @@ function handleLocalTrackPublished(publication) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user