【m】增加画质选择

This commit is contained in:
2026-04-25 21:55:47 +08:00
parent d48ce78c03
commit fd06063d83
5 changed files with 331 additions and 32 deletions

View File

@@ -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); }
}

View File

@@ -468,14 +468,53 @@
</button>
<!-- 更多选项 -->
<button
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group">
<i class="fas fa-ellipsis-h text-lg"></i>
<span
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
更多选项
</span>
</button>
<div class="relative">
<button id="moreOptionsBtn"
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group">
<i class="fas fa-ellipsis-h text-lg"></i>
<span
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
更多选项
</span>
</button>
<!-- 更多选项下拉菜单 -->
<div id="moreOptionsMenu" class="hidden absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 glass rounded-xl shadow-lg w-52 z-50">
<!-- 分辨率选项 -->
<div class="p-3 border-b border-white/10">
<h4 class="text-xs font-medium text-gray-400 mb-2 flex items-center gap-2">
<i class="fas fa-desktop text-xs"></i>
视频分辨率
</h4>
<div class="space-y-1" id="resolutionOptions">
<button onclick="changeResolution(480, 270)" data-resolution="480"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>流畅 480p</span>
<span class="text-xs text-gray-500">省流量</span>
</button>
<button onclick="changeResolution(1280, 720)" data-resolution="720"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>高清 720p</span>
<span class="text-xs text-gray-500">推荐</span>
</button>
<button onclick="changeResolution(1920, 1080)" data-resolution="1080"
class="resolution-option active w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>超清 1080p</span>
<span class="text-xs text-gray-500"></span>
</button>
<button onclick="changeResolution(2560, 1440)" data-resolution="1440"
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
<span>2K 1440p</span>
<span class="text-xs text-gray-500">最高画质</span>
</button>
</div>
</div>
<!-- 当前分辨率指示 -->
<div class="px-3 py-2 flex items-center gap-2">
<i class="fas fa-info-circle text-xs text-gray-500"></i>
<span id="currentResolutionText" class="text-xs text-gray-500">当前: 1080p</span>
</div>
</div>
</div>
<!-- 结束通话 -->
<!-- [EVENT: onclick => endCall()] [API: POST /api/call/:callId/leave] -->

View File

@@ -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);

View File

@@ -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) {
// 同步更新侧边栏用户列表

View File

@@ -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] - 目标participanthost端使用
*/
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 = [{}];
}
// 设置最大比特率为 4Mbps1080p@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) {