From fd06063d83d3fc7f2c7910497bddf13ba5189a31 Mon Sep 17 00:00:00 2001 From: stary <834207172@qq.COM> Date: Sat, 25 Apr 2026 21:55:47 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90m=E3=80=91=E5=A2=9E=E5=8A=A0=E7=94=BB?= =?UTF-8?q?=E8=B4=A8=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebApp/client/public/onebyone/css/style.css | 31 +++ WebApp/client/public/onebyone/index.html | 55 ++++- WebApp/client/public/onebyone/main.js | 35 ++++ WebApp/client/public/onebyone/renderer.js | 26 +++ WebApp/client/public/onebyone/store.js | 216 +++++++++++++++++--- 5 files changed, 331 insertions(+), 32 deletions(-) diff --git a/WebApp/client/public/onebyone/css/style.css b/WebApp/client/public/onebyone/css/style.css index fd20bde..7277e68 100644 --- a/WebApp/client/public/onebyone/css/style.css +++ b/WebApp/client/public/onebyone/css/style.css @@ -222,3 +222,34 @@ body { } [data-field]:hover::after { opacity: 1; } [data-field] { position: relative; }*/ + +/* 分辨率选项样式 */ +.resolution-option { + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + transition: all 0.2s; +} +.resolution-option:hover { + color: white; +} +.resolution-option.active { + background: rgba(99, 102, 241, 0.3); + color: white; +} +.resolution-option.active::before { + content: '\f00c'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + font-size: 10px; + margin-right: 6px; + color: #818cf8; +} + +/* 更多选项菜单动画 */ +#moreOptionsMenu { + animation: menuFadeIn 0.15s ease-out; +} +@keyframes menuFadeIn { + from { opacity: 0; transform: translateX(-50%) translateY(8px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} diff --git a/WebApp/client/public/onebyone/index.html b/WebApp/client/public/onebyone/index.html index 2a0c5a0..e92815b 100644 --- a/WebApp/client/public/onebyone/index.html +++ b/WebApp/client/public/onebyone/index.html @@ -468,14 +468,53 @@ - +
+ + + +
diff --git a/WebApp/client/public/onebyone/main.js b/WebApp/client/public/onebyone/main.js index 6c5a2f1..3138180 100644 --- a/WebApp/client/public/onebyone/main.js +++ b/WebApp/client/public/onebyone/main.js @@ -51,6 +51,24 @@ function bindDomEvents() { } }; + // 更多选项菜单切换 + window.toggleMoreOptions = function () { + const menu = document.getElementById('moreOptionsMenu'); + if (menu) { + menu.classList.toggle('hidden'); + } + }; + + // 切换视频分辨率 + window.changeResolution = function (width, height) { + store.changeResolution(width, height); + // 关闭菜单 + const menu = document.getElementById('moreOptionsMenu'); + if (menu) { + menu.classList.add('hidden'); + } + }; + // 结束通话 window.endCall = function () { // 显示确认对话框 @@ -130,6 +148,23 @@ function bindDomEvents() { document.getElementById('cancelEndCall').addEventListener('click', window.cancelEndCall); document.getElementById('confirmEndCall').addEventListener('click', window.confirmEndCall); + // 更多选项按钮事件 + const moreOptionsBtn = document.getElementById('moreOptionsBtn'); + if (moreOptionsBtn) { + moreOptionsBtn.addEventListener('click', window.toggleMoreOptions); + } + + // 点击外部关闭更多选项菜单 + document.addEventListener('click', function(event) { + const moreOptionsMenu = document.getElementById('moreOptionsMenu'); + const moreOptionsBtnEl = document.getElementById('moreOptionsBtn'); + if (moreOptionsMenu && moreOptionsBtnEl && + !moreOptionsMenu.contains(event.target) && + !moreOptionsBtnEl.contains(event.target)) { + moreOptionsMenu.classList.add('hidden'); + } + }); + // 绑定通话请求对话框事件 if (document.getElementById('rejectCall')) { document.getElementById('rejectCall').addEventListener('click', window.rejectCall); diff --git a/WebApp/client/public/onebyone/renderer.js b/WebApp/client/public/onebyone/renderer.js index b1ea71f..d2197a5 100644 --- a/WebApp/client/public/onebyone/renderer.js +++ b/WebApp/client/public/onebyone/renderer.js @@ -206,6 +206,10 @@ class UIRenderer { // participant离开 - 更新UI但房间仍然存在 this.renderParticipantLeft(changes.connectionId); break; + case 'RESOLUTION_CHANGED': + // 分辨率变化 - 更新UI中的分辨率选中状态 + this.renderResolutionChanged(changes.resolution); + break; } } @@ -254,6 +258,28 @@ class UIRenderer { } } + // 渲染分辨率变化 + renderResolutionChanged(resolution) { + if (!resolution) return; + + // 更新分辨率选项的选中状态 + const options = document.querySelectorAll('.resolution-option'); + options.forEach(btn => { + const btnRes = btn.dataset.resolution; + const isActive = (resolution.height >= 1440 && btnRes === '1440') || + (resolution.height >= 1080 && resolution.height < 1440 && btnRes === '1080') || + (resolution.height >= 720 && resolution.height < 1080 && btnRes === '720') || + (resolution.height < 720 && btnRes === '480'); + btn.classList.toggle('active', isActive); + }); + + // 更新当前分辨率文本 + const currentResText = document.getElementById('currentResolutionText'); + if (currentResText) { + currentResText.textContent = `当前: ${resolution.label}`; + } + } + // 渲染远端视频 renderRemoteVideo(remoteUser) { // 同步更新侧边栏用户列表 diff --git a/WebApp/client/public/onebyone/store.js b/WebApp/client/public/onebyone/store.js index 38e9af5..c520e9f 100644 --- a/WebApp/client/public/onebyone/store.js +++ b/WebApp/client/public/onebyone/store.js @@ -22,12 +22,20 @@ const VAD_CONFIG = { }; const MEDIA_CONSTRAINTS = { - video: true, + video: { + width: { ideal: 1920, max: 1920 }, + height: { ideal: 1080, max: 1080 }, + frameRate: { ideal: 30, max: 30 } + }, audio: AUDIO_CONFIG }; const VIDEO_ONLY_CONSTRAINT = { - video: true, + video: { + width: { ideal: 1920, max: 1920 }, + height: { ideal: 1080, max: 1080 }, + frameRate: { ideal: 30, max: 30 } + }, audio: false }; @@ -92,6 +100,12 @@ class CallStateManager { // 通知UI更新 this.notify({ type: 'USER_SETTINGS_UPDATED', user: this.state.session.localUser }); } + + // 恢复保存的分辨率设置 + if (settings.resolution) { + this._savedResolution = settings.resolution; + console.log(`已恢复分辨率设置: ${settings.resolution.width}x${settings.resolution.height}`); + } } catch (error) { console.error('Error loading user settings:', error); } @@ -113,7 +127,18 @@ class CallStateManager { } // 请求摄像头权限并获取媒体流,启用回声消除 - const stream = await navigator.mediaDevices.getUserMedia(MEDIA_CONSTRAINTS); + // 使用保存的分辨率(如有),否则使用默认约束 + const videoConstraints = this._savedResolution + ? { + width: { ideal: this._savedResolution.width, max: this._savedResolution.width }, + height: { ideal: this._savedResolution.height, max: this._savedResolution.height }, + frameRate: { ideal: 30, max: 30 } + } + : MEDIA_CONSTRAINTS.video; + const stream = await navigator.mediaDevices.getUserMedia({ + video: videoConstraints, + audio: AUDIO_CONFIG + }); console.log('Stream obtained successfully:', stream); console.log('Video tracks:', stream.getVideoTracks()); @@ -211,6 +236,7 @@ class CallStateManager { // 设置编解码器偏好 setTimeout(() => { this.setCodecPreferences(participantId); }, 100); + setTimeout(() => { this.setVideoEncodingParameters(participantId); }, 200); } } else { // Participant端:使用单一peer @@ -239,6 +265,7 @@ class CallStateManager { } } setTimeout(() => { this.setCodecPreferences(); }, 100); + setTimeout(() => { this.setVideoEncodingParameters(); }, 200); } } @@ -325,21 +352,6 @@ class CallStateManager { // 创建信令实例 const signaling = this.useWebSocket ? new WebSocketSignaling() : new Signaling(); const config = getRTCConfiguration(); // 获取RTC配置 - // 优化RTC配置,确保支持高分辨率和良好的音频处理 - config.peerConnectionOptions = { - optional: [ - { googCpuOveruseDetection: false }, // 禁用CPU过度使用检测 - { googScreencastMinBitrate: 3000 }, // 设置最小比特率 - { googEchoCancellation: true }, // 启用回声消除 - { googEchoCancellation2: true }, // 启用高级回声消除 - { googNoiseSuppression: true }, // 启用噪声抑制 - { googNoiseSuppression2: true }, // 启用高级噪声抑制 - { googAutoGainControl: true }, // 启用自动增益控制 - { googAutoGainControl2: true }, // 启用高级自动增益控制 - { googHighpassFilter: true }, // 启用高通滤波器 - { googTypingNoiseDetection: true } // 启用打字噪声检测 - ] - }; this.renderstreaming = new RenderStreaming(signaling, config); } @@ -366,6 +378,7 @@ class CallStateManager { this.renderstreaming.addTransceiver(track, { direction: 'sendonly' }, participantId); } this.setCodecPreferences(participantId); + this.setVideoEncodingParameters(participantId); } }; @@ -832,16 +845,38 @@ class CallStateManager { /** * 设置编解码器偏好 + * 优先选择 VP9/AV1(更高效的压缩),回退到 H264 High Profile */ setCodecPreferences(participantId) { - let selectedCodecs = null; const { codecs } = RTCRtpSender.getCapabilities('video'); - if (codecs && codecs.length > 0) { - const h264Codec = codecs.find(c => c.mimeType === 'video/H264'); - if (h264Codec) { - selectedCodecs = [h264Codec]; - } + if (!codecs || codecs.length === 0) return; + + let selectedCodecs = null; + + // 优先级: AV1 > VP9 > H264 High Profile > H264 + const av1Codec = codecs.find(c => c.mimeType === 'video/AV1'); + const vp9Codec = codecs.find(c => c.mimeType === 'video/VP9'); + // H264 High Profile 提供比 Baseline/Constrained Baseline 更好的画质 + const h264HighCodec = codecs.find(c => + c.mimeType === 'video/H264' && + c.sdpFmtpLine && c.sdpFmtpLine.includes('profile-level-id=6400') + ); + const h264Codec = codecs.find(c => c.mimeType === 'video/H264'); + + if (av1Codec) { + selectedCodecs = [av1Codec]; + console.log('Selected codec: AV1'); + } else if (vp9Codec) { + selectedCodecs = [vp9Codec]; + console.log('Selected codec: VP9'); + } else if (h264HighCodec) { + selectedCodecs = [h264HighCodec]; + console.log('Selected codec: H264 High Profile'); + } else if (h264Codec) { + selectedCodecs = [h264Codec]; + console.log('Selected codec: H264'); } + if (selectedCodecs == null) return; if (this.renderstreaming) { @@ -855,6 +890,139 @@ class CallStateManager { } } + /** + * 设置视频发送编码参数 + * 提升 maxBitrate 以改善远端视频画质 + * @param {string} [participantId] - 目标participant(host端使用) + */ + setVideoEncodingParameters(participantId) { + if (!this.renderstreaming) return; + + const transceivers = this.renderstreaming.getTransceivers(participantId); + if (!transceivers || transceivers.length === 0) return; + + const videoTransceivers = transceivers.filter(t => + t.sender && t.sender.track && t.sender.track.kind === 'video' + ); + + for (const transceiver of videoTransceivers) { + try { + const sender = transceiver.sender; + const params = sender.getParameters(); + + if (!params.encodings || params.encodings.length === 0) { + params.encodings = [{}]; + } + + // 设置最大比特率为 4Mbps(1080p@30fps 良好画质) + params.encodings[0].maxBitrate = 4000000; // 4 Mbps = 4,000,000 bps + + // 优先保持分辨率,降低帧率来适应带宽 + // 'maintain-resolution' 在带宽不足时保持清晰度 + if (params.degradationPreference !== undefined) { + params.degradationPreference = 'maintain-resolution'; + } + + sender.setParameters(params); + console.log(`Set video encoding: maxBitrate=4Mbps, degradationPreference=maintain-resolution${participantId ? ` for ${participantId}` : ''}`); + } catch (error) { + console.error('Error setting video encoding parameters:', error); + } + } + } + + + /** + * 动态切换视频分辨率 + * 使用 MediaStreamTrack.applyConstraints() 在通话中实时调整 + * 同时根据分辨率调整编码比特率 + * @param {number} width - 目标宽度 + * @param {number} height - 目标高度 + */ + async changeResolution(width, height) { + if (!this.state.localStream) { + showNotification('本地视频流不可用', 'error'); + return; + } + + const videoTracks = this.state.localStream.getVideoTracks(); + if (videoTracks.length === 0) { + showNotification('视频轨道不可用', 'error'); + return; + } + + const track = videoTracks[0]; + const label = height >= 1440 ? '2K 1440p' : height >= 1080 ? '1080p 超清' : height >= 720 ? '720p 高清' : '480p 流畅'; + + try { + // 使用 applyConstraints 在不重新获取流的情况下调整分辨率 + await track.applyConstraints({ + width: { ideal: width, max: width }, + height: { ideal: height, max: height }, + frameRate: { ideal: 30, max: 30 } + }); + + console.log(`分辨率已切换为 ${width}x${height}`); + + // 根据分辨率调整编码比特率 + // 480p: ~1Mbps, 720p: ~2.5Mbps, 1080p: ~4Mbps, 2K: ~6Mbps + const bitrateMap = { + 270: 1000000, // 480p + 720: 2500000, // 720p + 1080: 4000000, // 1080p + 1440: 6000000 // 2K + }; + const maxBitrate = bitrateMap[height] || 2500000; + this._applyMaxBitrate(maxBitrate); + + // 保存当前分辨率设置到本地存储 + const userSettings = JSON.parse(localStorage.getItem('userSettings') || '{}'); + userSettings.resolution = { width, height }; + localStorage.setItem('userSettings', JSON.stringify(userSettings)); + + // 通知 UI 更新 + this.notify({ type: 'RESOLUTION_CHANGED', resolution: { width, height, label } }); + showNotification(`已切换为 ${label}`, 'success'); + } catch (error) { + console.error('切换分辨率失败:', error); + showNotification('切换分辨率失败,摄像头不支持该分辨率', 'error'); + } + } + + /** + * 对所有视频 sender 应用 maxBitrate + * @param {number} maxBitrate - 最大比特率(bps) + */ + _applyMaxBitrate(maxBitrate) { + if (!this.renderstreaming) return; + + const isHost = this.role === 'host'; + const participantIds = isHost ? Object.keys(this.state.participants) : [null]; + + for (const pid of participantIds) { + const transceivers = this.renderstreaming.getTransceivers(pid); + if (!transceivers) continue; + + const videoTransceivers = transceivers.filter(t => + t.sender && t.sender.track && t.sender.track.kind === 'video' + ); + + for (const transceiver of videoTransceivers) { + try { + const sender = transceiver.sender; + const params = sender.getParameters(); + if (!params.encodings || params.encodings.length === 0) { + params.encodings = [{}]; + } + params.encodings[0].maxBitrate = maxBitrate; + sender.setParameters(params); + console.log(`Updated maxBitrate to ${maxBitrate} for ${pid || 'self'}`); + } catch (error) { + console.error('Error updating maxBitrate:', error); + } + } + } + } // 更新远端媒体状态 (由 WebSocket 触发) updateRemoteMedia(mediaState, participantId) {