This commit is contained in:
mofeng-git
2025-12-28 18:19:16 +08:00
commit d143d158e4
771 changed files with 220548 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
// Audio player composable - handles WebSocket connection, Opus decoding, and Web Audio API playback
import { ref, watch } from 'vue'
import { OpusDecoder } from 'opus-decoder'
// Binary protocol header format (15 bytes)
// [type:1][timestamp:4][duration:2][sequence:4][length:4][data:...]
export function useAudioPlayer() {
// State
const connected = ref(false)
const playing = ref(false)
const volume = ref(0) // Default to 0, user must adjust to enable audio (browser autoplay policy)
const error = ref<string | null>(null)
// Internal variables
let ws: WebSocket | null = null
let audioContext: AudioContext | null = null
let gainNode: GainNode | null = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let decoder: any = null
let nextPlayTime = 0
let isConnecting = false // Prevent concurrent connection attempts
// Initialize decoder
async function initDecoder() {
const opusDecoder = new OpusDecoder({
channels: 2,
sampleRate: 48000,
})
await opusDecoder.ready
decoder = opusDecoder
}
// Initialize audio context
function initAudioContext() {
audioContext = new AudioContext({ sampleRate: 48000 })
gainNode = audioContext.createGain()
gainNode.connect(audioContext.destination)
updateVolume()
}
// Connect to WebSocket
async function connect() {
// Prevent concurrent connection attempts (critical fix for multiple WS connections)
if (isConnecting) {
return
}
// Check if already connected
if (ws) {
if (ws.readyState === WebSocket.OPEN) {
return
}
if (ws.readyState === WebSocket.CONNECTING) {
return
}
// CLOSING or CLOSED - close and reconnect
ws.close()
ws = null
}
isConnecting = true
try {
// Initialize
if (!decoder) await initDecoder()
if (!audioContext) initAudioContext()
// Resume AudioContext (browser autoplay policy)
if (audioContext?.state === 'suspended') {
await audioContext.resume()
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${protocol}//${location.host}/api/ws/audio`
ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
isConnecting = false
connected.value = true
playing.value = true
error.value = null
nextPlayTime = audioContext!.currentTime
}
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
handleAudioPacket(event.data)
}
}
ws.onclose = () => {
isConnecting = false
connected.value = false
playing.value = false
}
ws.onerror = () => {
isConnecting = false
error.value = 'WebSocket connection failed'
}
} catch (e) {
isConnecting = false
error.value = e instanceof Error ? e.message : 'Failed to initialize audio'
}
}
// Disconnect
function disconnect() {
if (ws) {
ws.close()
ws = null
}
connected.value = false
playing.value = false
}
// Handle audio packet
function handleAudioPacket(buffer: ArrayBuffer) {
if (!decoder || !audioContext || !gainNode) {
return
}
if (audioContext.state !== 'running') {
audioContext.resume()
}
try {
// Parse Opus data (skip 15 bytes header)
const opusData = new Uint8Array(buffer, 15)
// Decode Opus -> PCM
const decoded = decoder.decodeFrame(opusData)
if (!decoded || !decoded.channelData || decoded.channelData.length === 0) {
return
}
const samplesPerChannel = decoded.samplesDecoded
const channels = decoded.channelData.length
// Create audio buffer
const audioBuffer = audioContext.createBuffer(
channels,
samplesPerChannel,
48000
)
// Fill channel data
for (let ch = 0; ch < channels; ch++) {
const channelData = audioBuffer.getChannelData(ch)
const sourceData = decoded.channelData[ch]
if (sourceData) {
channelData.set(sourceData)
}
}
// Schedule playback
const source = audioContext.createBufferSource()
source.buffer = audioBuffer
source.connect(gainNode)
const now = audioContext.currentTime
const scheduledAhead = nextPlayTime - now
// Reset if too far behind (audio was paused/lagged)
if (nextPlayTime < now) {
nextPlayTime = now + 0.02 // Start 20ms ahead
}
// Reset if buffer too large (> 500ms ahead)
if (scheduledAhead > 0.5) {
nextPlayTime = now + 0.05 // Keep 50ms buffer
}
source.start(nextPlayTime)
nextPlayTime += audioBuffer.duration
} catch {
// Ignore decode errors
}
}
// Update volume
function updateVolume() {
if (gainNode) {
gainNode.gain.value = volume.value
}
}
// Set volume
function setVolume(v: number) {
volume.value = Math.max(0, Math.min(1, v))
updateVolume()
}
// Watch volume changes
watch(volume, updateVolume)
return {
// State
connected,
playing,
volume,
error,
// Methods
connect,
disconnect,
setVolume,
}
}
// Singleton export
let instance: ReturnType<typeof useAudioPlayer> | null = null
export function getAudioPlayer() {
if (!instance) {
instance = useAudioPlayer()
}
return instance
}

View File

@@ -0,0 +1,335 @@
// WebSocket HID channel for low-latency keyboard/mouse input (binary protocol)
// Uses the same binary format as WebRTC DataChannel for consistency
import { ref, onUnmounted } from 'vue'
export interface HidKeyboardEvent {
type: 'keydown' | 'keyup'
key: number
modifiers?: {
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
}
export interface HidMouseEvent {
type: 'move' | 'moveabs' | 'down' | 'up' | 'scroll'
x?: number
y?: number
button?: number // 0=left, 1=middle, 2=right
scroll?: number
}
// Binary message constants (must match datachannel.rs)
const MSG_KEYBOARD = 0x01
const MSG_MOUSE = 0x02
// Keyboard event types
const KB_EVENT_DOWN = 0x00
const KB_EVENT_UP = 0x01
// Mouse event types
const MS_EVENT_MOVE = 0x00
const MS_EVENT_MOVE_ABS = 0x01
const MS_EVENT_DOWN = 0x02
const MS_EVENT_UP = 0x03
const MS_EVENT_SCROLL = 0x04
// Response codes from server
const RESP_OK = 0x00
const RESP_ERR_HID_UNAVAILABLE = 0x01
const RESP_ERR_INVALID_MESSAGE = 0x02
let wsInstance: WebSocket | null = null
const connected = ref(false)
const reconnectAttempts = ref(0)
const networkError = ref(false)
const networkErrorMessage = ref<string | null>(null)
const RECONNECT_DELAY = 3000
let reconnectTimeout: number | null = null
const hidUnavailable = ref(false) // Track if HID is unavailable to prevent unnecessary reconnects
// Mouse throttle mechanism
let mouseThrottleMs = 10
let lastMouseSendTime = 0
let pendingMouseEvent: HidMouseEvent | null = null
let throttleTimer: number | null = null
// Connection promise to avoid race conditions
let connectionPromise: Promise<boolean> | null = null
let connectionResolved = false
// Encode keyboard event to binary format
function encodeKeyboardEvent(event: HidKeyboardEvent): ArrayBuffer {
const buffer = new ArrayBuffer(4)
const view = new DataView(buffer)
view.setUint8(0, MSG_KEYBOARD)
view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP)
view.setUint8(2, event.key & 0xff)
// Build modifiers bitmask
let modifiers = 0
if (event.modifiers?.ctrl) modifiers |= 0x01 // Left Ctrl
if (event.modifiers?.shift) modifiers |= 0x02 // Left Shift
if (event.modifiers?.alt) modifiers |= 0x04 // Left Alt
if (event.modifiers?.meta) modifiers |= 0x08 // Left Meta
view.setUint8(3, modifiers)
return buffer
}
// Encode mouse event to binary format
function encodeMouseEvent(event: HidMouseEvent): ArrayBuffer {
const buffer = new ArrayBuffer(7)
const view = new DataView(buffer)
view.setUint8(0, MSG_MOUSE)
// Event type
let eventType = MS_EVENT_MOVE
switch (event.type) {
case 'move':
eventType = MS_EVENT_MOVE
break
case 'moveabs':
eventType = MS_EVENT_MOVE_ABS
break
case 'down':
eventType = MS_EVENT_DOWN
break
case 'up':
eventType = MS_EVENT_UP
break
case 'scroll':
eventType = MS_EVENT_SCROLL
break
}
view.setUint8(1, eventType)
// X coordinate (i16 LE)
view.setInt16(2, event.x ?? 0, true)
// Y coordinate (i16 LE)
view.setInt16(4, event.y ?? 0, true)
// Button or scroll delta
if (event.type === 'down' || event.type === 'up') {
view.setUint8(6, event.button ?? 0)
} else if (event.type === 'scroll') {
view.setInt8(6, event.scroll ?? 0)
} else {
view.setUint8(6, 0)
}
return buffer
}
function connect(): Promise<boolean> {
// If already connected, return immediately
if (wsInstance && wsInstance.readyState === WebSocket.OPEN && connectionResolved) {
return Promise.resolve(true)
}
// If connection is in progress, return the existing promise
if (connectionPromise && !connectionResolved) {
return connectionPromise
}
connectionResolved = false
connectionPromise = new Promise((resolve) => {
// Reset network error flag when attempting new connection
networkError.value = false
networkErrorMessage.value = null
hidUnavailable.value = false
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${protocol}//${window.location.host}/api/ws/hid`
try {
wsInstance = new WebSocket(url)
wsInstance.binaryType = 'arraybuffer'
wsInstance.onopen = () => {
connected.value = true
networkError.value = false
reconnectAttempts.value = 0
}
wsInstance.onmessage = (e) => {
// Handle binary response
if (e.data instanceof ArrayBuffer) {
const view = new DataView(e.data)
if (view.byteLength >= 1) {
const code = view.getUint8(0)
if (code === RESP_OK) {
hidUnavailable.value = false
networkError.value = false
connectionResolved = true
resolve(true)
} else if (code === RESP_ERR_HID_UNAVAILABLE) {
// HID is not available, mark it and don't trigger reconnection
hidUnavailable.value = true
networkError.value = false
connectionResolved = true
resolve(true)
} else if (code === RESP_ERR_INVALID_MESSAGE) {
console.warn('[HID] Server rejected message as invalid')
}
}
}
}
wsInstance.onclose = () => {
connected.value = false
connectionResolved = false
connectionPromise = null
// Don't auto-reconnect if HID is unavailable
if (hidUnavailable.value) {
resolve(false)
return
}
// Auto-reconnect with infinite retry for network errors
networkError.value = true
networkErrorMessage.value = 'HID WebSocket disconnected'
reconnectAttempts.value++
reconnectTimeout = window.setTimeout(() => connect(), RECONNECT_DELAY)
}
wsInstance.onerror = () => {
networkError.value = true
networkErrorMessage.value = 'Network connection failed'
connectionResolved = false
connectionPromise = null
resolve(false)
}
} catch (err) {
console.error('[HID] Failed to create connection:', err)
connectionResolved = false
connectionPromise = null
resolve(false)
}
})
return connectionPromise
}
function disconnect() {
if (reconnectTimeout !== null) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (wsInstance) {
// Close the websocket
wsInstance.close()
wsInstance = null
connected.value = false
networkError.value = false
}
// Reset connection state
connectionPromise = null
connectionResolved = false
}
function sendKeyboard(event: HidKeyboardEvent): Promise<void> {
return new Promise((resolve, reject) => {
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not connected'))
return
}
try {
wsInstance.send(encodeKeyboardEvent(event))
resolve()
} catch (err) {
reject(err)
}
})
}
// Set mouse throttle interval (0-1000ms, 0 = no throttle)
export function setMouseThrottle(ms: number) {
mouseThrottleMs = Math.max(0, Math.min(1000, ms))
}
// Internal function to actually send mouse event
function _sendMouseInternal(event: HidMouseEvent): Promise<void> {
return new Promise((resolve, reject) => {
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket not connected'))
return
}
try {
wsInstance.send(encodeMouseEvent(event))
resolve()
} catch (err) {
reject(err)
}
})
}
// Throttled mouse event sender
function sendMouse(event: HidMouseEvent): Promise<void> {
return new Promise((resolve, reject) => {
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
// 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)
}
})
}
export function useHidWebSocket() {
onUnmounted(() => {
// Don't disconnect on component unmount - WebSocket is shared
// Only disconnect when explicitly called or page unloads
})
return {
connected,
reconnectAttempts,
networkError,
networkErrorMessage,
hidUnavailable,
connect,
disconnect,
sendKeyboard,
sendMouse,
}
}
// Global lifecycle - disconnect when page unloads
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
disconnect()
})
}

View File

@@ -0,0 +1,226 @@
// Unified Audio Manager
// Manages audio playback across different video modes (MJPEG/WebSocket and H264/WebRTC)
// Provides a single interface for volume control and audio source switching
import { ref, watch, type Ref } from 'vue'
import { getAudioPlayer } from './useAudioPlayer'
export type AudioMode = 'ws' | 'webrtc'
export interface UnifiedAudioState {
audioMode: Ref<AudioMode>
volume: Ref<number>
muted: Ref<boolean>
connected: Ref<boolean>
playing: Ref<boolean>
error: Ref<string | null>
}
export function useUnifiedAudio() {
// === State ===
const audioMode = ref<AudioMode>('ws')
const volume = ref(0) // 0-1, default muted (browser autoplay policy)
const muted = ref(false)
const connected = ref(false)
const playing = ref(false)
const error = ref<string | null>(null)
// === Internal References ===
const wsPlayer = getAudioPlayer()
let webrtcVideoElement: HTMLVideoElement | null = null
// === Methods ===
/**
* Set the WebRTC video element reference
* This is needed to control WebRTC audio volume
*/
function setWebRTCElement(el: HTMLVideoElement | null) {
// Only update if element is provided (don't clear on null to preserve reference)
if (el) {
webrtcVideoElement = el
// Sync current volume to video element
el.volume = volume.value
// Mute if volume is 0 or explicitly muted
const shouldMute = muted.value || volume.value === 0
el.muted = shouldMute
}
}
/**
* Switch audio mode between WebSocket and WebRTC
* Automatically handles connection state
*/
async function switchMode(mode: AudioMode) {
if (mode === audioMode.value) return
const wasConnected = connected.value
const wasPlaying = playing.value
// Disconnect old mode
if (audioMode.value === 'ws') {
wsPlayer.disconnect()
}
// WebRTC audio doesn't need manual disconnect, handled by video element
audioMode.value = mode
// If was connected/playing and volume > 0, auto-connect new mode
if ((wasConnected || wasPlaying) && volume.value > 0) {
await connect()
}
// Update connection state
updateConnectionState()
}
/**
* Set volume (0-1)
* Applies to both WS and WebRTC audio
*/
function setVolume(v: number) {
const newVolume = Math.max(0, Math.min(1, v))
volume.value = newVolume
// Sync to WS player
wsPlayer.setVolume(newVolume)
// Sync to WebRTC video element
if (webrtcVideoElement) {
const shouldMute = muted.value || newVolume === 0
webrtcVideoElement.volume = newVolume
webrtcVideoElement.muted = shouldMute
}
}
/**
* Set muted state
*/
function setMuted(m: boolean) {
muted.value = m
// WS player: control via volume (no separate mute)
if (audioMode.value === 'ws') {
wsPlayer.setVolume(m ? 0 : volume.value)
}
// WebRTC video element
if (webrtcVideoElement) {
webrtcVideoElement.muted = m || volume.value === 0
}
}
/**
* Toggle muted state
*/
function toggleMute() {
setMuted(!muted.value)
}
/**
* Connect audio based on current mode
*/
async function connect() {
error.value = null
if (audioMode.value === 'ws') {
try {
await wsPlayer.connect()
connected.value = wsPlayer.connected.value
playing.value = wsPlayer.playing.value
} catch (e) {
error.value = e instanceof Error ? e.message : 'WS audio connect failed'
}
} else {
// WebRTC audio is automatically connected via video track
// Just ensure video element is not muted (if volume > 0)
if (webrtcVideoElement) {
webrtcVideoElement.muted = muted.value || volume.value === 0
connected.value = true
playing.value = !webrtcVideoElement.muted
}
}
}
/**
* Disconnect audio
*/
function disconnect() {
if (audioMode.value === 'ws') {
wsPlayer.disconnect()
}
// WebRTC audio: mute but don't disconnect (follows video element)
if (webrtcVideoElement) {
webrtcVideoElement.muted = true
}
connected.value = false
playing.value = false
}
/**
* Update connection state based on current mode
*/
function updateConnectionState() {
if (audioMode.value === 'ws') {
connected.value = wsPlayer.connected.value
playing.value = wsPlayer.playing.value
} else {
// WebRTC mode: check if video element has audio and is not muted
connected.value = webrtcVideoElement !== null
playing.value = webrtcVideoElement !== null && !webrtcVideoElement.muted
}
}
// Watch WS player state changes
watch(() => wsPlayer.connected.value, (newConnected) => {
if (audioMode.value === 'ws') {
connected.value = newConnected
}
})
watch(() => wsPlayer.playing.value, (newPlaying) => {
if (audioMode.value === 'ws') {
playing.value = newPlaying
}
})
watch(() => wsPlayer.error.value, (newError) => {
if (audioMode.value === 'ws') {
error.value = newError
}
})
return {
// State
audioMode,
volume,
muted,
connected,
playing,
error,
// Methods
setWebRTCElement,
switchMode,
setVolume,
setMuted,
toggleMute,
connect,
disconnect,
}
}
// Singleton instance
let instance: ReturnType<typeof useUnifiedAudio> | null = null
/**
* Get the singleton unified audio manager instance
*/
export function getUnifiedAudio() {
if (!instance) {
instance = useUnifiedAudio()
}
return instance
}

View File

@@ -0,0 +1,641 @@
// WebRTC composable for H264 video streaming
// Provides low-latency video via WebRTC with DataChannel for HID
import { ref, onUnmounted, computed, type Ref } from 'vue'
import { webrtcApi } from '@/api'
import { generateUUID } from '@/lib/utils'
export type WebRTCState = 'disconnected' | 'connecting' | 'connected' | 'failed'
// ICE candidate type: host=P2P local, srflx=P2P STUN, relay=TURN relay
export type IceCandidateType = 'host' | 'srflx' | 'prflx' | 'relay' | 'unknown'
export interface WebRTCStats {
bytesReceived: number
packetsReceived: number
packetsLost: number
framesDecoded: number
framesDropped: number
frameWidth: number
frameHeight: number
framesPerSecond: number
jitter: number
roundTripTime: number
// ICE connection info
localCandidateType: IceCandidateType
remoteCandidateType: IceCandidateType
transportProtocol: string // 'udp' | 'tcp'
isRelay: boolean // true if using TURN relay
}
export interface HidKeyboardEvent {
type: 'keydown' | 'keyup'
key: number
modifiers?: {
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
}
export interface HidMouseEvent {
type: 'move' | 'moveabs' | 'down' | 'up' | 'scroll'
x?: number
y?: number
button?: number
scroll?: number
}
// Binary message constants (must match datachannel.rs)
const MSG_KEYBOARD = 0x01
const MSG_MOUSE = 0x02
// Keyboard event types
const KB_EVENT_DOWN = 0x00
const KB_EVENT_UP = 0x01
// Mouse event types
const MS_EVENT_MOVE = 0x00
const MS_EVENT_MOVE_ABS = 0x01
const MS_EVENT_DOWN = 0x02
const MS_EVENT_UP = 0x03
const MS_EVENT_SCROLL = 0x04
// Encode keyboard event to binary format
function encodeKeyboardEvent(event: HidKeyboardEvent): ArrayBuffer {
const buffer = new ArrayBuffer(4)
const view = new DataView(buffer)
view.setUint8(0, MSG_KEYBOARD)
view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP)
view.setUint8(2, event.key & 0xff)
// Build modifiers bitmask
let modifiers = 0
if (event.modifiers?.ctrl) modifiers |= 0x01 // Left Ctrl
if (event.modifiers?.shift) modifiers |= 0x02 // Left Shift
if (event.modifiers?.alt) modifiers |= 0x04 // Left Alt
if (event.modifiers?.meta) modifiers |= 0x08 // Left Meta
view.setUint8(3, modifiers)
return buffer
}
// Encode mouse event to binary format
function encodeMouseEvent(event: HidMouseEvent): ArrayBuffer {
const buffer = new ArrayBuffer(7)
const view = new DataView(buffer)
view.setUint8(0, MSG_MOUSE)
// Event type
let eventType = MS_EVENT_MOVE
switch (event.type) {
case 'move':
eventType = MS_EVENT_MOVE
break
case 'moveabs':
eventType = MS_EVENT_MOVE_ABS
break
case 'down':
eventType = MS_EVENT_DOWN
break
case 'up':
eventType = MS_EVENT_UP
break
case 'scroll':
eventType = MS_EVENT_SCROLL
break
}
view.setUint8(1, eventType)
// X coordinate (i16 LE)
view.setInt16(2, event.x ?? 0, true)
// Y coordinate (i16 LE)
view.setInt16(4, event.y ?? 0, true)
// Button or scroll delta
if (event.type === 'down' || event.type === 'up') {
view.setUint8(6, event.button ?? 0)
} else if (event.type === 'scroll') {
view.setInt8(6, event.scroll ?? 0)
} else {
view.setUint8(6, 0)
}
return buffer
}
// Cached ICE servers from backend API
let cachedIceServers: RTCIceServer[] | null = null
// Fetch ICE servers from backend API
async function fetchIceServers(): Promise<RTCIceServer[]> {
try {
const response = await webrtcApi.getIceServers()
if (response.ice_servers && response.ice_servers.length > 0) {
cachedIceServers = response.ice_servers.map(server => ({
urls: server.urls,
username: server.username,
credential: server.credential,
}))
console.log('[WebRTC] ICE servers loaded from API:', cachedIceServers.length)
return cachedIceServers
}
} catch (err) {
console.warn('[WebRTC] Failed to fetch ICE servers from API, using fallback:', err)
}
// Fallback: for local connections, use no ICE servers (host candidates only)
// For remote connections, use Google STUN as fallback
const isLocalConnection = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname.startsWith('192.168.') ||
window.location.hostname.startsWith('10.'))
if (isLocalConnection) {
console.log('[WebRTC] Local connection detected, using host candidates only')
return []
}
console.log('[WebRTC] Using fallback STUN servers')
return [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
]
}
// Shared instance state
let peerConnection: RTCPeerConnection | null = null
let dataChannel: RTCDataChannel | null = null
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
const state = ref<WebRTCState>('disconnected')
const videoTrack = ref<MediaStreamTrack | null>(null)
const audioTrack = ref<MediaStreamTrack | null>(null)
const stats = ref<WebRTCStats>({
bytesReceived: 0,
packetsReceived: 0,
packetsLost: 0,
framesDecoded: 0,
framesDropped: 0,
frameWidth: 0,
frameHeight: 0,
framesPerSecond: 0,
jitter: 0,
roundTripTime: 0,
localCandidateType: 'unknown',
remoteCandidateType: 'unknown',
transportProtocol: '',
isRelay: false,
})
const error = ref<string | null>(null)
const dataChannelReady = ref(false)
// Create RTCPeerConnection with configuration
function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
const config: RTCConfiguration = {
iceServers,
iceCandidatePoolSize: 10,
}
const pc = new RTCPeerConnection(config)
// Handle connection state changes
pc.onconnectionstatechange = () => {
switch (pc.connectionState) {
case 'connecting':
state.value = 'connecting'
break
case 'connected':
state.value = 'connected'
error.value = null
startStatsCollection()
break
case 'disconnected':
case 'closed':
state.value = 'disconnected'
stopStatsCollection()
break
case 'failed':
state.value = 'failed'
error.value = 'Connection failed'
stopStatsCollection()
break
}
}
// Handle ICE connection state
pc.oniceconnectionstatechange = () => {
// ICE state changes handled silently
}
// Handle ICE candidates
pc.onicecandidate = async (event) => {
if (!event.candidate) return
const currentSessionId = sessionId
if (currentSessionId && pc.connectionState !== 'closed') {
// Session ready, send immediately
try {
await webrtcApi.addIceCandidate(currentSessionId, {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid ?? undefined,
sdpMLineIndex: event.candidate.sdpMLineIndex ?? undefined,
usernameFragment: event.candidate.usernameFragment ?? undefined,
})
} catch {
// ICE candidate send failures are non-fatal
}
} else if (!currentSessionId) {
// Queue candidate until sessionId is set
pendingIceCandidates.push(event.candidate)
}
}
// Handle incoming tracks
pc.ontrack = (event) => {
const track = event.track
if (track.kind === 'video') {
videoTrack.value = track
} else if (track.kind === 'audio') {
audioTrack.value = track
}
}
// Handle data channel from server
pc.ondatachannel = (event) => {
setupDataChannel(event.channel)
}
return pc
}
// Setup data channel event handlers
function setupDataChannel(channel: RTCDataChannel) {
dataChannel = channel
channel.onopen = () => {
dataChannelReady.value = true
}
channel.onclose = () => {
dataChannelReady.value = false
}
channel.onerror = () => {
// Data channel errors handled silently
}
channel.onmessage = () => {
// Handle incoming messages from server (e.g., LED status)
}
}
// Create data channel for HID events
function createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
const channel = pc.createDataChannel('hid', {
ordered: true,
maxRetransmits: 3,
})
setupDataChannel(channel)
return channel
}
// Start collecting stats
function startStatsCollection() {
if (statsInterval) return
statsInterval = window.setInterval(async () => {
if (!peerConnection) return
try {
const report = await peerConnection.getStats()
// Collect candidate info
const candidates: Record<string, { type: IceCandidateType; protocol: string }> = {}
let selectedPairLocalId = ''
let selectedPairRemoteId = ''
let foundActivePair = false
report.forEach((stat) => {
// Collect all candidates
if (stat.type === 'local-candidate' || stat.type === 'remote-candidate') {
candidates[stat.id] = {
type: (stat.candidateType as IceCandidateType) || 'unknown',
protocol: stat.protocol || '',
}
}
// Find the active candidate pair
// Priority: nominated > succeeded (for Chrome/Firefox compatibility)
if (stat.type === 'candidate-pair') {
// Check if this is the nominated/selected pair
const isActive = stat.nominated === true ||
(stat.state === 'succeeded' && stat.selected === true) ||
(stat.state === 'in-progress' && !foundActivePair)
// Also check if this pair has actual data transfer (more reliable indicator)
const hasData = (stat.bytesReceived > 0 || stat.bytesSent > 0)
if ((isActive || (stat.state === 'succeeded' && hasData)) && !foundActivePair) {
stats.value.roundTripTime = stat.currentRoundTripTime || 0
selectedPairLocalId = stat.localCandidateId || ''
selectedPairRemoteId = stat.remoteCandidateId || ''
if (stat.nominated === true || stat.selected === true) {
foundActivePair = true
}
}
}
// Update video stats
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
stats.value.bytesReceived = stat.bytesReceived || 0
stats.value.packetsReceived = stat.packetsReceived || 0
stats.value.packetsLost = stat.packetsLost || 0
stats.value.framesDecoded = stat.framesDecoded || 0
stats.value.framesDropped = stat.framesDropped || 0
stats.value.frameWidth = stat.frameWidth || 0
stats.value.frameHeight = stat.frameHeight || 0
stats.value.framesPerSecond = stat.framesPerSecond || 0
stats.value.jitter = stat.jitter || 0
}
})
// Update ICE connection info from selected pair
const localCandidate = selectedPairLocalId ? candidates[selectedPairLocalId] : undefined
const remoteCandidate = selectedPairRemoteId ? candidates[selectedPairRemoteId] : undefined
if (localCandidate) {
stats.value.localCandidateType = localCandidate.type
stats.value.transportProtocol = localCandidate.protocol
}
if (remoteCandidate) {
stats.value.remoteCandidateType = remoteCandidate.type
}
// Check if using TURN relay
// TURN relay is when either local or remote candidate is 'relay' type
stats.value.isRelay = stats.value.localCandidateType === 'relay' || stats.value.remoteCandidateType === 'relay'
} catch {
// Stats collection errors are non-fatal
}
}, 1000)
}
// Stop collecting stats
function stopStatsCollection() {
if (statsInterval) {
clearInterval(statsInterval)
statsInterval = null
}
}
// Send queued ICE candidates after sessionId is set
async function flushPendingIceCandidates() {
if (!sessionId || pendingIceCandidates.length === 0) return
const candidates = [...pendingIceCandidates]
pendingIceCandidates = []
for (const candidate of candidates) {
try {
await webrtcApi.addIceCandidate(sessionId, {
candidate: candidate.candidate,
sdpMid: candidate.sdpMid ?? undefined,
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
usernameFragment: candidate.usernameFragment ?? undefined,
})
} catch {
// ICE candidate send failures are non-fatal
}
}
}
// Connect to WebRTC server
async function connect(): Promise<boolean> {
// Prevent concurrent connection attempts
if (isConnecting) {
return false
}
if (peerConnection && state.value === 'connected') {
return true
}
isConnecting = true
// Clean up any existing connection first
if (peerConnection || sessionId) {
await disconnect()
}
// Clear pending ICE candidates from previous attempt
pendingIceCandidates = []
try {
state.value = 'connecting'
error.value = null
// Fetch ICE servers from backend API
const iceServers = await fetchIceServers()
// Create peer connection with fetched ICE servers
peerConnection = createPeerConnection(iceServers)
// Create data channel before offer (for HID)
createDataChannel(peerConnection)
// Add transceiver for receiving video
peerConnection.addTransceiver('video', { direction: 'recvonly' })
peerConnection.addTransceiver('audio', { direction: 'recvonly' })
// Create offer
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)
// Send offer to server and get answer
const response = await webrtcApi.offer(offer.sdp!, generateUUID())
sessionId = response.session_id
// Send any ICE candidates that were queued while waiting for sessionId
await flushPendingIceCandidates()
// Set remote description (answer)
const answer: RTCSessionDescriptionInit = {
type: 'answer',
sdp: response.sdp,
}
await peerConnection.setRemoteDescription(answer)
// Add any ICE candidates from the response
if (response.ice_candidates && response.ice_candidates.length > 0) {
for (const candidateObj of response.ice_candidates) {
try {
const iceCandidate: RTCIceCandidateInit = {
candidate: candidateObj.candidate,
sdpMid: candidateObj.sdpMid ?? '0',
sdpMLineIndex: candidateObj.sdpMLineIndex ?? 0,
}
await peerConnection.addIceCandidate(iceCandidate)
} catch {
// ICE candidate add failures are non-fatal
}
}
}
isConnecting = false
return true
} catch (err) {
state.value = 'failed'
error.value = err instanceof Error ? err.message : 'Connection failed'
isConnecting = false
disconnect()
return false
}
}
// Disconnect from WebRTC server
async function disconnect() {
stopStatsCollection()
// Clear state FIRST to prevent ICE candidates from being sent
const oldSessionId = sessionId
sessionId = null
isConnecting = false
pendingIceCandidates = []
if (dataChannel) {
dataChannel.close()
dataChannel = null
dataChannelReady.value = false
}
if (peerConnection) {
peerConnection.close()
peerConnection = null
}
if (oldSessionId) {
try {
await webrtcApi.close(oldSessionId)
} catch {
// Ignore close errors
}
}
videoTrack.value = null
audioTrack.value = null
state.value = 'disconnected'
error.value = null
// Reset stats
stats.value = {
bytesReceived: 0,
packetsReceived: 0,
packetsLost: 0,
framesDecoded: 0,
framesDropped: 0,
frameWidth: 0,
frameHeight: 0,
framesPerSecond: 0,
jitter: 0,
roundTripTime: 0,
localCandidateType: 'unknown',
remoteCandidateType: 'unknown',
transportProtocol: '',
isRelay: false,
}
}
// Send keyboard event via DataChannel (binary format)
function sendKeyboard(event: HidKeyboardEvent): boolean {
if (!dataChannel || dataChannel.readyState !== 'open') {
return false
}
try {
const buffer = encodeKeyboardEvent(event)
dataChannel.send(buffer)
return true
} catch {
return false
}
}
// Send mouse event via DataChannel (binary format)
function sendMouse(event: HidMouseEvent): boolean {
if (!dataChannel || dataChannel.readyState !== 'open') {
return false
}
try {
const buffer = encodeMouseEvent(event)
dataChannel.send(buffer)
return true
} catch {
return false
}
}
// Get MediaStream for video element
function getMediaStream(): MediaStream | null {
if (!videoTrack.value && !audioTrack.value) {
return null
}
const stream = new MediaStream()
if (videoTrack.value) {
stream.addTrack(videoTrack.value)
}
if (audioTrack.value) {
stream.addTrack(audioTrack.value)
}
return stream
}
// Composable export
export function useWebRTC() {
onUnmounted(() => {
// Don't disconnect on unmount - keep connection alive
// Only disconnect when explicitly called
})
return {
// State
state: state as Ref<WebRTCState>,
videoTrack,
audioTrack,
stats,
error,
dataChannelReady,
sessionId: computed(() => sessionId),
// Methods
connect,
disconnect,
sendKeyboard,
sendMouse,
getMediaStream,
// Computed
isConnected: computed(() => state.value === 'connected'),
isConnecting: computed(() => state.value === 'connecting'),
hasVideo: computed(() => videoTrack.value !== null),
hasAudio: computed(() => audioTrack.value !== null),
}
}
// Cleanup on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
disconnect()
})
}

View File

@@ -0,0 +1,147 @@
// WebSocket composable for real-time event streaming
//
// Usage:
// const { connected, on, off } = useWebSocket()
// on('stream.state_changed', (data) => { ... })
import { ref } from 'vue'
export interface WsEvent {
event: string
data: any
}
type EventHandler = (data: any) => void
let wsInstance: WebSocket | null = null
let handlers = new Map<string, EventHandler[]>()
const connected = ref(false)
const reconnectAttempts = ref(0)
const networkError = ref(false)
const networkErrorMessage = ref<string | null>(null)
const RECONNECT_DELAY = 3000
function connect() {
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
return
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${protocol}//${window.location.host}/api/ws`
try {
wsInstance = new WebSocket(url)
wsInstance.onopen = () => {
connected.value = true
networkError.value = false
networkErrorMessage.value = null
reconnectAttempts.value = 0
// Subscribe to all events by default
subscribe(['*'])
}
wsInstance.onmessage = (e) => {
try {
const event: WsEvent = JSON.parse(e.data)
if (event.event === 'error') {
console.error('[WebSocket] Server error:', event.data?.message)
} else {
handleEvent(event)
}
} catch (err) {
console.error('[WebSocket] Failed to parse message:', err)
}
}
wsInstance.onclose = () => {
connected.value = false
networkError.value = true
// Auto-reconnect with infinite retry
reconnectAttempts.value++
setTimeout(connect, RECONNECT_DELAY)
}
wsInstance.onerror = () => {
networkError.value = true
networkErrorMessage.value = 'Network connection failed'
}
} catch (err) {
console.error('[WebSocket] Failed to create connection:', err)
}
}
function disconnect() {
if (wsInstance) {
wsInstance.close()
wsInstance = null
}
}
function subscribe(topics: string[]) {
if (wsInstance && wsInstance.readyState === WebSocket.OPEN) {
wsInstance.send(JSON.stringify({
type: 'subscribe',
payload: { topics }
}))
}
}
function on(event: string, handler: EventHandler) {
if (!handlers.has(event)) {
handlers.set(event, [])
}
handlers.get(event)!.push(handler)
}
function off(event: string, handler: EventHandler) {
const eventHandlers = handlers.get(event)
if (eventHandlers) {
const index = eventHandlers.indexOf(handler)
if (index > -1) {
eventHandlers.splice(index, 1)
}
}
}
function handleEvent(payload: WsEvent) {
const eventName = payload.event
const eventHandlers = handlers.get(eventName)
if (eventHandlers) {
eventHandlers.forEach(handler => {
try {
handler(payload.data)
} catch (err) {
console.error(`[WebSocket] Error in handler for ${eventName}:`, err)
}
})
}
// Silently ignore events without handlers
}
export function useWebSocket() {
// Connection is now triggered manually by components after registering handlers
return {
connected,
reconnectAttempts,
networkError,
networkErrorMessage,
on,
off,
subscribe,
connect,
disconnect,
}
}
// Global lifecycle - disconnect when page unloads
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
disconnect()
})
}