mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-18 08:56:50 +08:00
feat(webrtc): 添加公共ICE服务器支持和优化HID延迟
- 重构ICE配置:将TURN配置改为统一的ICE配置,支持STUN和多TURN URL - 添加公共ICE服务器:类似RustDesk,用户留空时使用编译时配置的公共服务器 - 优化DataChannel HID消息:使用tokio::spawn立即处理,避免依赖webrtc-rs轮询 - 添加WebRTCReady事件:客户端等待此事件后再建立连接 - 初始化时启动音频流,确保WebRTC可订阅 - 移除多余的trace/debug日志减少开销 - 更新前端配置界面支持公共ICE服务器显示
This commit is contained in:
@@ -25,7 +25,7 @@ const networkErrorMessage = ref<string | null>(null)
|
||||
let reconnectTimeout: number | null = null
|
||||
const hidUnavailable = ref(false) // Track if HID is unavailable to prevent unnecessary reconnects
|
||||
|
||||
// Mouse throttle mechanism
|
||||
// Mouse throttle mechanism (10ms = 100Hz for smoother cursor movement)
|
||||
let mouseThrottleMs = 10
|
||||
let lastMouseSendTime = 0
|
||||
let pendingMouseEvent: HidMouseEvent | null = null
|
||||
@@ -183,36 +183,40 @@ function _sendMouseInternal(event: HidMouseEvent): Promise<void> {
|
||||
}
|
||||
|
||||
// Throttled mouse event sender
|
||||
// Note: Returns immediately for throttled events to avoid Promise memory leak.
|
||||
// When an event is throttled, we store it as pending and resolve immediately.
|
||||
// A timer will send the pending event later, but that's fire-and-forget.
|
||||
function sendMouse(event: HidMouseEvent): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastMouseSendTime
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastMouseSendTime
|
||||
|
||||
if (elapsed >= mouseThrottleMs) {
|
||||
// Send immediately if enough time has passed
|
||||
lastMouseSendTime = now
|
||||
_sendMouseInternal(event).then(resolve).catch(reject)
|
||||
} else {
|
||||
// Queue the event and send after throttle period
|
||||
pendingMouseEvent = event
|
||||
if (elapsed >= mouseThrottleMs) {
|
||||
// Send immediately if enough time has passed
|
||||
lastMouseSendTime = now
|
||||
return _sendMouseInternal(event)
|
||||
} else {
|
||||
// Throttle: store event for later, resolve immediately to avoid Promise leak
|
||||
pendingMouseEvent = event
|
||||
|
||||
// Clear existing timer
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
|
||||
// Schedule send after remaining throttle time
|
||||
throttleTimer = window.setTimeout(() => {
|
||||
if (pendingMouseEvent) {
|
||||
lastMouseSendTime = Date.now()
|
||||
_sendMouseInternal(pendingMouseEvent)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
pendingMouseEvent = null
|
||||
}
|
||||
}, mouseThrottleMs - elapsed)
|
||||
// Clear existing timer and set a new one
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// Schedule send after remaining throttle time (fire-and-forget)
|
||||
throttleTimer = window.setTimeout(() => {
|
||||
if (pendingMouseEvent) {
|
||||
lastMouseSendTime = Date.now()
|
||||
_sendMouseInternal(pendingMouseEvent).catch(() => {
|
||||
// Silently ignore errors for throttled events
|
||||
})
|
||||
pendingMouseEvent = null
|
||||
}
|
||||
}, mouseThrottleMs - elapsed)
|
||||
|
||||
// Resolve immediately - the event is queued, caller doesn't need to wait
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// Send consumer control event (multimedia keys)
|
||||
|
||||
@@ -83,6 +83,7 @@ let sessionId: string | null = null
|
||||
let statsInterval: number | null = null
|
||||
let isConnecting = false // Lock to prevent concurrent connect calls
|
||||
let pendingIceCandidates: RTCIceCandidate[] = [] // Queue for ICE candidates before sessionId is set
|
||||
let cachedMediaStream: MediaStream | null = null // Cached MediaStream to avoid recreating
|
||||
|
||||
const state = ref<WebRTCState>('disconnected')
|
||||
const videoTrack = ref<MediaStreamTrack | null>(null)
|
||||
@@ -399,8 +400,28 @@ async function connect(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
isConnecting = false
|
||||
return true
|
||||
// 等待连接真正建立(最多等待 15 秒)
|
||||
// 直接检查 peerConnection.connectionState 而不是 reactive state
|
||||
// 因为 TypeScript 不知道 state 会被 onconnectionstatechange 回调异步修改
|
||||
const connectionTimeout = 15000
|
||||
const pollInterval = 100
|
||||
let waited = 0
|
||||
|
||||
while (waited < connectionTimeout && peerConnection) {
|
||||
const pcState = peerConnection.connectionState
|
||||
if (pcState === 'connected') {
|
||||
isConnecting = false
|
||||
return true
|
||||
}
|
||||
if (pcState === 'failed' || pcState === 'closed') {
|
||||
throw new Error('Connection failed during ICE negotiation')
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
waited += pollInterval
|
||||
}
|
||||
|
||||
// 超时
|
||||
throw new Error('Connection timeout waiting for ICE negotiation')
|
||||
} catch (err) {
|
||||
state.value = 'failed'
|
||||
error.value = err instanceof Error ? err.message : 'Connection failed'
|
||||
@@ -441,6 +462,7 @@ async function disconnect() {
|
||||
|
||||
videoTrack.value = null
|
||||
audioTrack.value = null
|
||||
cachedMediaStream = null // Clear cached stream on disconnect
|
||||
state.value = 'disconnected'
|
||||
error.value = null
|
||||
|
||||
@@ -493,20 +515,49 @@ function sendMouse(event: HidMouseEvent): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Get MediaStream for video element
|
||||
// Get MediaStream for video element (cached to avoid recreating)
|
||||
function getMediaStream(): MediaStream | null {
|
||||
if (!videoTrack.value && !audioTrack.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stream = new MediaStream()
|
||||
// Reuse cached stream if tracks match
|
||||
if (cachedMediaStream) {
|
||||
const currentVideoTracks = cachedMediaStream.getVideoTracks()
|
||||
const currentAudioTracks = cachedMediaStream.getAudioTracks()
|
||||
|
||||
const videoMatches = videoTrack.value
|
||||
? currentVideoTracks.includes(videoTrack.value)
|
||||
: currentVideoTracks.length === 0
|
||||
const audioMatches = audioTrack.value
|
||||
? currentAudioTracks.includes(audioTrack.value)
|
||||
: currentAudioTracks.length === 0
|
||||
|
||||
if (videoMatches && audioMatches) {
|
||||
return cachedMediaStream
|
||||
}
|
||||
|
||||
// Tracks changed, update the cached stream
|
||||
// Remove old tracks
|
||||
currentVideoTracks.forEach(t => cachedMediaStream!.removeTrack(t))
|
||||
currentAudioTracks.forEach(t => cachedMediaStream!.removeTrack(t))
|
||||
|
||||
// Add new tracks
|
||||
if (videoTrack.value) cachedMediaStream.addTrack(videoTrack.value)
|
||||
if (audioTrack.value) cachedMediaStream.addTrack(audioTrack.value)
|
||||
|
||||
return cachedMediaStream
|
||||
}
|
||||
|
||||
// Create new cached stream
|
||||
cachedMediaStream = new MediaStream()
|
||||
if (videoTrack.value) {
|
||||
stream.addTrack(videoTrack.value)
|
||||
cachedMediaStream.addTrack(videoTrack.value)
|
||||
}
|
||||
if (audioTrack.value) {
|
||||
stream.addTrack(audioTrack.value)
|
||||
cachedMediaStream.addTrack(audioTrack.value)
|
||||
}
|
||||
return stream
|
||||
return cachedMediaStream
|
||||
}
|
||||
|
||||
// Composable export
|
||||
|
||||
Reference in New Issue
Block a user