diff --git a/components.d.ts b/components.d.ts index 35af6d4..761e12e 100644 --- a/components.d.ts +++ b/components.d.ts @@ -39,6 +39,7 @@ declare module 'vue' { ElRow: typeof import('element-plus/es')['ElRow'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElSlider: typeof import('element-plus/es')['ElSlider'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] diff --git a/src/views/Cinema.vue b/src/views/Cinema.vue index 988c97c..fbab3a4 100644 --- a/src/views/Cinema.vue +++ b/src/views/Cinema.vue @@ -284,10 +284,94 @@ const setPlayerStatus = (status: Status) => { let peerConnections: { [key: string]: RTCPeerConnection } = {}; let localStream = ref(undefined); +let remoteAudioElements: {[key: string]: HTMLAudioElement} = {}; + +// 音频设备列表 +const audioInputDevices = ref([]); +const audioOutputDevices = ref([]); +const selectedAudioInput = ref(""); +const selectedAudioOutput = ref(""); + +const outputVolume = ref(1.0); // 扬声器音量 +const isMuted = ref(false); // 麦克风静音状态 + +// 获取音频设备列表 +const getAudioDevices = async () => { + const devices = await navigator.mediaDevices.enumerateDevices(); + audioInputDevices.value = devices.filter((device) => device.kind === "audioinput"); + audioOutputDevices.value = devices.filter((device) => device.kind === "audiooutput"); +}; + +// 切换麦克风静音状态 +const toggleMute = () => { + if (!localStream.value) return; + isMuted.value = !isMuted.value; + localStream.value.getAudioTracks().forEach(track => { + track.enabled = !isMuted.value; + }); +}; + +// 切换麦克风 +const switchMicrophone = async () => { + if (!localStream.value) return; + + try { + const newStream = await navigator.mediaDevices.getUserMedia({ + audio: { + deviceId: selectedAudioInput.value, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + } + }); + + // 停止旧轨道 + localStream.value.getTracks().forEach((track) => track.stop()); + + // 替换所有PeerConnection中的轨道 + const [audioTrack] = newStream.getTracks(); + audioTrack.enabled = !isMuted.value; // 保持当前的静音状态 + + for (const pc of Object.values(peerConnections)) { + const sender = pc.getSenders().find((s) => s.track?.kind === "audio"); + if (sender) { + await sender.replaceTrack(audioTrack); + } + } + + localStream.value = newStream; + } catch (err) { + ElMessage.error(`切换麦克风失败: ${err}`); + } +}; + +// 切换扬声器 +const switchSpeaker = async () => { + try { + for (const audio of Object.values(remoteAudioElements)) { + if ("setSinkId" in audio) { + await (audio as any).setSinkId(selectedAudioOutput.value); + audio.volume = outputVolume.value; + } + } + } catch (err) { + ElMessage.error(`切换扬声器失败: ${err}`); + } +}; + +// 调整扬声器音量 +const adjustOutputVolume = () => { + Object.values(remoteAudioElements).forEach((audio) => { + audio.volume = outputVolume.value; + }); +}; const joinWebRTC = async () => { try { - localStream.value = await navigator.mediaDevices.getUserMedia({ audio: true }); + await getAudioDevices(); + localStream.value = await navigator.mediaDevices.getUserMedia({ + audio: selectedAudioInput.value ? { deviceId: selectedAudioInput.value } : true + }); } catch (err) { ElMessage.error(`获取媒体流失败!${err}`); return; @@ -329,6 +413,24 @@ const handleWebrtcJoin = async (msg: Message) => { ); }; +const handleWebrtcLeave = async (msg: Message) => { + closePeerConnection(msg.webrtcData!.from); +}; + +const closePeerConnection = (id: string) => { + const pc = peerConnections[id]; + if (pc) { + pc.close(); + delete peerConnections[id]; + } + const remoteAudio = remoteAudioElements[id]; + if (remoteAudio) { + remoteAudio.pause(); + remoteAudio.srcObject = null; + delete remoteAudioElements[id]; + } +}; + const handleWebrtcOffer = async (msg: Message) => { const data = JSON.parse(msg.webrtcData!.data); const pc = createPeerConnection(msg.webrtcData!.from); @@ -349,7 +451,8 @@ const handleWebrtcOffer = async (msg: Message) => { const createPeerConnection = (id: string) => { const pc = new RTCPeerConnection({ - iceServers: [{ urls: "stun:stun.l.google.com:19302" }] + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + iceCandidatePoolSize: 10 }); pc.onicecandidate = (event) => { if (event.candidate) { @@ -365,14 +468,20 @@ const createPeerConnection = (id: string) => { } }; pc.ontrack = (event) => { - // const remoteStream = event.streams[0]; - // if (remoteStream) { - // const videoElement = document.createElement("video"); - // videoElement.srcObject = remoteStream; - // videoElement.play(); - // } const remoteAudio = document.createElement("audio"); remoteAudio.srcObject = event.streams[0]; + remoteAudio.volume = outputVolume.value; + if (selectedAudioOutput.value && "setSinkId" in remoteAudio) { + (remoteAudio as any).setSinkId(selectedAudioOutput.value).catch((error: any) => { + console.error("扬声器设置失败:", error); + }); + } + remoteAudio.style.display = "none"; + remoteAudio.onended = () => { + document.body.removeChild(remoteAudio); + delete remoteAudioElements[id]; + }; + remoteAudioElements[id] = remoteAudio; remoteAudio.play().catch((error) => { console.error("Audio playback failed:", error); }); @@ -417,7 +526,10 @@ const handleElementMessage = (msg: Message) => { handleWebrtcJoin(msg); break; } - + case MessageType.WEBRTC_LEAVE: { + handleWebrtcLeave(msg); + break; + } case MessageType.WEBRTC_OFFER: { handleWebrtcOffer(msg); break; @@ -519,12 +631,25 @@ const chatArea = ref(); // 设置聊天框高度 const resetChatAreaHeight = () => { const h = playArea.value ? playArea : noPlayArea; - chatArea && h && (chatArea.value.style.height = h.value.scrollHeight - 112 + "px"); + if (!chatArea.value || !h.value) return; + + // 计算基础高度 + let baseHeight = h.value.scrollHeight - 112; + + // 如果有语音控制面板,减去其高度 + if (audioControls.value && audioControls.value instanceof HTMLElement) { + baseHeight -= audioControls.value.offsetHeight + 16; // 16px for margin + } + + chatArea.value.style.height = `${baseHeight}px`; }; const card = ref(null); useResizeObserver(card, resetChatAreaHeight); +const audioControls = ref(null); +useResizeObserver(audioControls, resetChatAreaHeight); + const can = (p: RoomMemberPermission) => { if (!myInfo.value) return; const myP = myInfo.value.permissions; @@ -591,6 +716,16 @@ onMounted(async () => { ); await p(); + + // 获取初始音频设备列表 + await getAudioDevices(); + + // 监听设备变化 + navigator.mediaDevices.addEventListener("devicechange", getAudioDevices); +}); + +onBeforeUnmount(() => { + navigator.mediaDevices.removeEventListener("devicechange", getAudioDevices); }); @@ -649,6 +784,55 @@ onMounted(async () => { >退出语音 +
+
+ + + + + + + + +
+ 扬声器音量: + +
+ + + {{ isMuted ? '取消闭麦' : '闭麦' }} + +
+
@@ -715,6 +899,7 @@ onMounted(async () => { .chatArea { overflow-y: scroll; height: 67vh; + transition: height 0.3s ease; } .loading-spinner { @@ -741,6 +926,54 @@ onMounted(async () => { } } +.audio-controls-container { + transition: all 0.3s ease-in-out; + transform-origin: top; + animation: slideDown 0.3s ease-in-out; +} + +.audio-controls { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 10px; + + .el-select { + width: 100%; + animation: fadeIn 0.3s ease-in-out; + } + + .volume-control { + animation: fadeIn 0.3s ease-in-out 0.1s; + } + + .el-button { + animation: fadeIn 0.3s ease-in-out 0.2s; + } +} + +@keyframes slideDown { + from { + transform: scaleY(0); + opacity: 0; + } + to { + transform: scaleY(1); + opacity: 1; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + @keyframes bounce { 0%, 80%,