mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-29 09:01:54 +08:00
init
This commit is contained in:
221
web/src/composables/useAudioPlayer.ts
Normal file
221
web/src/composables/useAudioPlayer.ts
Normal 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
|
||||
}
|
||||
335
web/src/composables/useHidWebSocket.ts
Normal file
335
web/src/composables/useHidWebSocket.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
226
web/src/composables/useUnifiedAudio.ts
Normal file
226
web/src/composables/useUnifiedAudio.ts
Normal 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
|
||||
}
|
||||
641
web/src/composables/useWebRTC.ts
Normal file
641
web/src/composables/useWebRTC.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
147
web/src/composables/useWebSocket.ts
Normal file
147
web/src/composables/useWebSocket.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user