mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-28 16:41:52 +08:00
feat(video): 事务化切换与前端统一编排,增强视频输入格式支持
- 后端:切换事务+transition_id,/stream/mode 返回 switching/transition_id 与实际 codec - 事件:新增 mode_switching/mode_ready,config/webrtc_ready/mode_changed 关联事务 - 编码/格式:扩展 NV21/NV16/NV24/RGB/BGR 输入与转换链路,RKMPP direct input 优化 - 前端:useVideoSession 统一切换,失败回退真实切回 MJPEG,菜单格式同步修复 - 清理:useVideoStream 降级为 MJPEG-only
This commit is contained in:
@@ -227,10 +227,10 @@ export const streamApi = {
|
||||
getSnapshotUrl: () => `${API_BASE}/snapshot`,
|
||||
|
||||
getMode: () =>
|
||||
request<{ success: boolean; mode: string; message?: string }>('/stream/mode'),
|
||||
request<{ success: boolean; mode: string; transition_id?: string; switching?: boolean; message?: string }>('/stream/mode'),
|
||||
|
||||
setMode: (mode: string) =>
|
||||
request<{ success: boolean; mode: string; message?: string }>('/stream/mode', {
|
||||
request<{ success: boolean; mode: string; transition_id?: string; switching?: boolean; message?: string }>('/stream/mode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
|
||||
@@ -189,6 +189,7 @@ const selectedFormat = ref<string>('')
|
||||
const selectedResolution = ref<string>('')
|
||||
const selectedFps = ref<number>(30)
|
||||
const selectedBitratePreset = ref<'Speed' | 'Balanced' | 'Quality'>('Balanced')
|
||||
const isDirty = ref(false)
|
||||
|
||||
// UI state
|
||||
const applying = ref(false)
|
||||
@@ -327,6 +328,25 @@ function initializeFromCurrent() {
|
||||
selectedFormat.value = config.format
|
||||
selectedResolution.value = `${config.width}x${config.height}`
|
||||
selectedFps.value = config.fps
|
||||
isDirty.value = false
|
||||
}
|
||||
|
||||
function syncFromCurrentIfChanged() {
|
||||
const config = currentConfig.value
|
||||
const nextResolution = `${config.width}x${config.height}`
|
||||
|
||||
if (selectedDevice.value === config.device
|
||||
&& selectedFormat.value === config.format
|
||||
&& selectedResolution.value === nextResolution
|
||||
&& selectedFps.value === config.fps) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedDevice.value = config.device
|
||||
selectedFormat.value = config.format
|
||||
selectedResolution.value = nextResolution
|
||||
selectedFps.value = config.fps
|
||||
isDirty.value = false
|
||||
}
|
||||
|
||||
// Handle video mode change
|
||||
@@ -339,6 +359,7 @@ function handleVideoModeChange(mode: unknown) {
|
||||
function handleDeviceChange(devicePath: unknown) {
|
||||
if (typeof devicePath !== 'string') return
|
||||
selectedDevice.value = devicePath
|
||||
isDirty.value = true
|
||||
|
||||
// Auto-select first format
|
||||
const device = devices.value.find(d => d.path === devicePath)
|
||||
@@ -358,6 +379,7 @@ function handleDeviceChange(devicePath: unknown) {
|
||||
function handleFormatChange(format: unknown) {
|
||||
if (typeof format !== 'string') return
|
||||
selectedFormat.value = format
|
||||
isDirty.value = true
|
||||
|
||||
// Auto-select first resolution for this format
|
||||
const formatData = availableFormats.value.find(f => f.format === format)
|
||||
@@ -372,6 +394,7 @@ function handleFormatChange(format: unknown) {
|
||||
function handleResolutionChange(resolution: unknown) {
|
||||
if (typeof resolution !== 'string') return
|
||||
selectedResolution.value = resolution
|
||||
isDirty.value = true
|
||||
|
||||
// Auto-select first FPS for this resolution
|
||||
const resolutionData = availableResolutions.value.find(
|
||||
@@ -386,6 +409,7 @@ function handleResolutionChange(resolution: unknown) {
|
||||
function handleFpsChange(fps: unknown) {
|
||||
if (typeof fps !== 'string' && typeof fps !== 'number') return
|
||||
selectedFps.value = typeof fps === 'string' ? Number(fps) : fps
|
||||
isDirty.value = true
|
||||
}
|
||||
|
||||
// Apply bitrate preset change
|
||||
@@ -427,6 +451,7 @@ async function applyVideoConfig() {
|
||||
})
|
||||
|
||||
toast.success(t('config.applied'))
|
||||
isDirty.value = false
|
||||
// Stream state will be updated via WebSocket system.device_info event
|
||||
} catch (e) {
|
||||
console.info('[VideoConfig] Failed to apply config:', e)
|
||||
@@ -455,8 +480,17 @@ watch(() => props.open, (isOpen) => {
|
||||
loadEncoderBackend()
|
||||
// Initialize from current config
|
||||
initializeFromCurrent()
|
||||
} else {
|
||||
isDirty.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Sync selected values when backend config changes (e.g., auto format switch on mode change)
|
||||
watch(currentConfig, () => {
|
||||
if (applying.value) return
|
||||
if (props.open && isDirty.value) return
|
||||
syncFromCurrentIfChanged()
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -12,6 +12,13 @@ export interface ConsoleEventHandlers {
|
||||
onStreamConfigApplied?: (data: { device: string; resolution: [number, number]; fps: number }) => void
|
||||
onStreamStatsUpdate?: (data: { clients?: number; clients_stat?: Record<string, { fps: number }> }) => void
|
||||
onStreamModeChanged?: (data: { mode: string; previous_mode: string }) => void
|
||||
onStreamModeSwitching?: (data: { transition_id: string; to_mode: string; from_mode: string }) => void
|
||||
onStreamModeReady?: (data: { transition_id: string; mode: string }) => void
|
||||
onWebRTCReady?: (data: { codec: string; hardware: boolean; transition_id?: string }) => void
|
||||
onStreamStateChanged?: (data: { state: string; device?: string | null }) => void
|
||||
onStreamDeviceLost?: (data: { device: string; reason: string }) => void
|
||||
onStreamReconnecting?: (data: { device: string; attempt: number }) => void
|
||||
onStreamRecovered?: (data: { device: string }) => void
|
||||
onDeviceInfo?: (data: any) => void
|
||||
onAudioStateChanged?: (data: { streaming: boolean; device: string | null }) => void
|
||||
}
|
||||
@@ -21,6 +28,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
const systemStore = useSystemStore()
|
||||
const { on, off, connect } = useWebSocket()
|
||||
const unifiedAudio = getUnifiedAudio()
|
||||
const noop = () => {}
|
||||
|
||||
// HID event handlers
|
||||
function handleHidStateChanged(_data: unknown) {
|
||||
@@ -68,6 +76,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
description: t('console.deviceLostDesc', { device: data.device, reason: data.reason }),
|
||||
duration: 5000,
|
||||
})
|
||||
handlers.onStreamDeviceLost?.(data)
|
||||
}
|
||||
|
||||
function handleStreamReconnecting(data: { device: string; attempt: number }) {
|
||||
@@ -77,6 +86,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
handlers.onStreamReconnecting?.(data)
|
||||
}
|
||||
|
||||
function handleStreamRecovered(_data: { device: string }) {
|
||||
@@ -87,6 +97,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
description: t('console.deviceRecoveredDesc'),
|
||||
duration: 3000,
|
||||
})
|
||||
handlers.onStreamRecovered?.(_data)
|
||||
}
|
||||
|
||||
function handleStreamStateChanged(data: { state: string }) {
|
||||
@@ -95,6 +106,11 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamStateChangedForward(data: { state: string; device?: string | null }) {
|
||||
handleStreamStateChanged(data)
|
||||
handlers.onStreamStateChanged?.(data)
|
||||
}
|
||||
|
||||
// Audio device monitoring handlers
|
||||
function handleAudioDeviceLost(data: { device?: string; reason: string; error_code: string }) {
|
||||
if (systemStore.audio) {
|
||||
@@ -183,11 +199,14 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
on('hid.recovered', handleHidRecovered)
|
||||
|
||||
// Stream events
|
||||
on('stream.config_changing', handlers.onStreamConfigChanging || (() => {}))
|
||||
on('stream.config_applied', handlers.onStreamConfigApplied || (() => {}))
|
||||
on('stream.stats_update', handlers.onStreamStatsUpdate || (() => {}))
|
||||
on('stream.mode_changed', handlers.onStreamModeChanged || (() => {}))
|
||||
on('stream.state_changed', handleStreamStateChanged)
|
||||
on('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
|
||||
on('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
|
||||
on('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
|
||||
on('stream.mode_changed', handlers.onStreamModeChanged ?? noop)
|
||||
on('stream.mode_switching', handlers.onStreamModeSwitching ?? noop)
|
||||
on('stream.mode_ready', handlers.onStreamModeReady ?? noop)
|
||||
on('stream.webrtc_ready', handlers.onWebRTCReady ?? noop)
|
||||
on('stream.state_changed', handleStreamStateChangedForward)
|
||||
on('stream.device_lost', handleStreamDeviceLost)
|
||||
on('stream.reconnecting', handleStreamReconnecting)
|
||||
on('stream.recovered', handleStreamRecovered)
|
||||
@@ -206,7 +225,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
on('msd.recovered', handleMsdRecovered)
|
||||
|
||||
// System events
|
||||
on('system.device_info', handlers.onDeviceInfo || (() => {}))
|
||||
on('system.device_info', handlers.onDeviceInfo ?? noop)
|
||||
|
||||
// Connect WebSocket
|
||||
connect()
|
||||
@@ -219,11 +238,14 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
off('hid.reconnecting', handleHidReconnecting)
|
||||
off('hid.recovered', handleHidRecovered)
|
||||
|
||||
off('stream.config_changing', handlers.onStreamConfigChanging || (() => {}))
|
||||
off('stream.config_applied', handlers.onStreamConfigApplied || (() => {}))
|
||||
off('stream.stats_update', handlers.onStreamStatsUpdate || (() => {}))
|
||||
off('stream.mode_changed', handlers.onStreamModeChanged || (() => {}))
|
||||
off('stream.state_changed', handleStreamStateChanged)
|
||||
off('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
|
||||
off('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
|
||||
off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
|
||||
off('stream.mode_changed', handlers.onStreamModeChanged ?? noop)
|
||||
off('stream.mode_switching', handlers.onStreamModeSwitching ?? noop)
|
||||
off('stream.mode_ready', handlers.onStreamModeReady ?? noop)
|
||||
off('stream.webrtc_ready', handlers.onWebRTCReady ?? noop)
|
||||
off('stream.state_changed', handleStreamStateChangedForward)
|
||||
off('stream.device_lost', handleStreamDeviceLost)
|
||||
off('stream.reconnecting', handleStreamReconnecting)
|
||||
off('stream.recovered', handleStreamRecovered)
|
||||
@@ -239,7 +261,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
|
||||
off('msd.error', handleMsdError)
|
||||
off('msd.recovered', handleMsdRecovered)
|
||||
|
||||
off('system.device_info', handlers.onDeviceInfo || (() => {}))
|
||||
off('system.device_info', handlers.onDeviceInfo ?? noop)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
185
web/src/composables/useVideoSession.ts
Normal file
185
web/src/composables/useVideoSession.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface StreamModeSwitchingEvent {
|
||||
transition_id: string
|
||||
to_mode: string
|
||||
from_mode: string
|
||||
}
|
||||
|
||||
export interface StreamModeReadyEvent {
|
||||
transition_id: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
export interface WebRTCReadyEvent {
|
||||
codec: string
|
||||
hardware: boolean
|
||||
transition_id?: string
|
||||
}
|
||||
|
||||
let singleton: ReturnType<typeof createVideoSession> | null = null
|
||||
|
||||
function createVideoSession() {
|
||||
const localSwitching = ref(false)
|
||||
const backendSwitching = ref(false)
|
||||
const activeTransitionId = ref<string | null>(null)
|
||||
const expectedTransitionId = ref<string | null>(null)
|
||||
|
||||
let lastUserSwitchAt = 0
|
||||
|
||||
let webrtcReadyWaiter: {
|
||||
transitionId: string
|
||||
resolve: (ready: boolean) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
} | null = null
|
||||
|
||||
let modeReadyWaiter: {
|
||||
transitionId: string
|
||||
resolve: (mode: string | null) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
} | null = null
|
||||
|
||||
function startLocalSwitch() {
|
||||
localSwitching.value = true
|
||||
}
|
||||
|
||||
function tryStartLocalSwitch(minIntervalMs = 800): boolean {
|
||||
const now = Date.now()
|
||||
if (localSwitching.value) return false
|
||||
if (now - lastUserSwitchAt < minIntervalMs) return false
|
||||
lastUserSwitchAt = now
|
||||
localSwitching.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
function endLocalSwitch() {
|
||||
localSwitching.value = false
|
||||
}
|
||||
|
||||
function clearWaiters() {
|
||||
if (webrtcReadyWaiter) {
|
||||
clearTimeout(webrtcReadyWaiter.timer)
|
||||
webrtcReadyWaiter.resolve(false)
|
||||
webrtcReadyWaiter = null
|
||||
}
|
||||
if (modeReadyWaiter) {
|
||||
clearTimeout(modeReadyWaiter.timer)
|
||||
modeReadyWaiter.resolve(null)
|
||||
modeReadyWaiter = null
|
||||
}
|
||||
expectedTransitionId.value = null
|
||||
}
|
||||
|
||||
function registerTransition(transitionId: string) {
|
||||
expectedTransitionId.value = transitionId
|
||||
activeTransitionId.value = transitionId
|
||||
backendSwitching.value = true
|
||||
}
|
||||
|
||||
function isStaleTransition(transitionId?: string): boolean {
|
||||
if (!transitionId) return false
|
||||
return expectedTransitionId.value !== null && transitionId !== expectedTransitionId.value
|
||||
}
|
||||
|
||||
function waitForWebRTCReady(transitionId: string, timeoutMs = 3000): Promise<boolean> {
|
||||
if (webrtcReadyWaiter) {
|
||||
clearTimeout(webrtcReadyWaiter.timer)
|
||||
webrtcReadyWaiter.resolve(false)
|
||||
webrtcReadyWaiter = null
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (webrtcReadyWaiter?.transitionId === transitionId) {
|
||||
webrtcReadyWaiter = null
|
||||
}
|
||||
resolve(false)
|
||||
}, timeoutMs)
|
||||
|
||||
webrtcReadyWaiter = {
|
||||
transitionId,
|
||||
resolve,
|
||||
timer,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function waitForModeReady(transitionId: string, timeoutMs = 5000): Promise<string | null> {
|
||||
if (modeReadyWaiter) {
|
||||
clearTimeout(modeReadyWaiter.timer)
|
||||
modeReadyWaiter.resolve(null)
|
||||
modeReadyWaiter = null
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (modeReadyWaiter?.transitionId === transitionId) {
|
||||
modeReadyWaiter = null
|
||||
}
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
|
||||
modeReadyWaiter = {
|
||||
transitionId,
|
||||
resolve,
|
||||
timer,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onModeSwitching(data: StreamModeSwitchingEvent) {
|
||||
if (localSwitching.value && expectedTransitionId.value && data.transition_id !== expectedTransitionId.value) {
|
||||
return
|
||||
}
|
||||
backendSwitching.value = true
|
||||
activeTransitionId.value = data.transition_id
|
||||
expectedTransitionId.value = data.transition_id
|
||||
}
|
||||
|
||||
function onModeReady(data: StreamModeReadyEvent) {
|
||||
if (isStaleTransition(data.transition_id)) return
|
||||
|
||||
backendSwitching.value = false
|
||||
activeTransitionId.value = null
|
||||
expectedTransitionId.value = null
|
||||
|
||||
if (modeReadyWaiter?.transitionId === data.transition_id) {
|
||||
clearTimeout(modeReadyWaiter.timer)
|
||||
modeReadyWaiter.resolve(data.mode)
|
||||
modeReadyWaiter = null
|
||||
}
|
||||
}
|
||||
|
||||
function onWebRTCReady(data: WebRTCReadyEvent) {
|
||||
if (isStaleTransition(data.transition_id)) return
|
||||
if (data.transition_id && webrtcReadyWaiter?.transitionId === data.transition_id) {
|
||||
clearTimeout(webrtcReadyWaiter.timer)
|
||||
webrtcReadyWaiter.resolve(true)
|
||||
webrtcReadyWaiter = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
localSwitching,
|
||||
backendSwitching,
|
||||
activeTransitionId,
|
||||
expectedTransitionId,
|
||||
startLocalSwitch,
|
||||
tryStartLocalSwitch,
|
||||
endLocalSwitch,
|
||||
clearWaiters,
|
||||
registerTransition,
|
||||
waitForWebRTCReady,
|
||||
waitForModeReady,
|
||||
onModeSwitching,
|
||||
onModeReady,
|
||||
onWebRTCReady,
|
||||
}
|
||||
}
|
||||
|
||||
export function useVideoSession(): ReturnType<typeof createVideoSession> {
|
||||
if (!singleton) {
|
||||
singleton = createVideoSession()
|
||||
}
|
||||
return singleton
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
// Video streaming composable - manages MJPEG/WebRTC video modes
|
||||
// Extracted from ConsoleView.vue for better separation of concerns
|
||||
// Legacy MJPEG-only streaming composable.
|
||||
// Deprecated: Console now uses useVideoSession for all switching/connection logic.
|
||||
|
||||
import { ref, computed, watch, type Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { streamApi } from '@/api'
|
||||
import { useWebRTC } from '@/composables/useWebRTC'
|
||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { streamApi } from '@/api'
|
||||
import { generateUUID } from '@/lib/utils'
|
||||
|
||||
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||
export type VideoMode = 'mjpeg'
|
||||
|
||||
export interface VideoStreamState {
|
||||
mode: Ref<VideoMode>
|
||||
@@ -23,23 +19,14 @@ export interface VideoStreamState {
|
||||
clientId: string
|
||||
}
|
||||
|
||||
export interface UseVideoStreamOptions {
|
||||
webrtcVideoRef: Ref<HTMLVideoElement | null>
|
||||
mjpegVideoRef: Ref<HTMLImageElement | null>
|
||||
}
|
||||
|
||||
// Retry configuration
|
||||
const BASE_RETRY_DELAY = 2000
|
||||
const GRACE_PERIOD = 2000
|
||||
const MAX_CONSECUTIVE_ERRORS = 2
|
||||
|
||||
export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
const { t } = useI18n()
|
||||
/** @deprecated Use useVideoSession + ConsoleView instead. */
|
||||
export function useVideoStream() {
|
||||
const systemStore = useSystemStore()
|
||||
const webrtc = useWebRTC()
|
||||
const unifiedAudio = getUnifiedAudio()
|
||||
|
||||
// State
|
||||
const videoMode = ref<VideoMode>('mjpeg')
|
||||
const videoLoading = ref(true)
|
||||
const videoError = ref(false)
|
||||
@@ -48,29 +35,21 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
const backendFps = ref(0)
|
||||
const mjpegTimestamp = ref(0)
|
||||
const clientId = generateUUID()
|
||||
|
||||
// Per-client statistics
|
||||
const clientsStats = ref<Record<string, { id: string; fps: number; connected_secs: number }>>({})
|
||||
|
||||
// Internal state
|
||||
let retryTimeoutId: number | null = null
|
||||
let retryCount = 0
|
||||
let gracePeriodTimeoutId: number | null = null
|
||||
let consecutiveErrors = 0
|
||||
let isRefreshingVideo = false
|
||||
let initialDeviceInfoReceived = false
|
||||
let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Computed
|
||||
const mjpegUrl = computed(() => {
|
||||
if (videoMode.value !== 'mjpeg') return ''
|
||||
if (mjpegTimestamp.value === 0) return ''
|
||||
return `${streamApi.getMjpegUrl(clientId)}&t=${mjpegTimestamp.value}`
|
||||
})
|
||||
|
||||
const isWebRTCMode = computed(() => videoMode.value !== 'mjpeg')
|
||||
|
||||
// Methods
|
||||
function refreshVideo() {
|
||||
backendFps.value = 0
|
||||
videoError.value = false
|
||||
@@ -107,7 +86,7 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
if (videoMode.value !== 'mjpeg') return
|
||||
if (isRefreshingVideo) return
|
||||
|
||||
consecutiveErrors++
|
||||
consecutiveErrors += 1
|
||||
|
||||
if (consecutiveErrors > MAX_CONSECUTIVE_ERRORS && gracePeriodTimeoutId !== null) {
|
||||
clearTimeout(gracePeriodTimeoutId)
|
||||
@@ -123,7 +102,7 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
}
|
||||
|
||||
videoLoading.value = true
|
||||
retryCount++
|
||||
retryCount += 1
|
||||
const delay = BASE_RETRY_DELAY * Math.pow(1.5, Math.min(retryCount - 1, 5))
|
||||
|
||||
retryTimeoutId = window.setTimeout(() => {
|
||||
@@ -143,153 +122,7 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
clearRetryTimers()
|
||||
retryCount = 0
|
||||
consecutiveErrors = 0
|
||||
mjpegTimestamp.value = 0
|
||||
|
||||
if (options.mjpegVideoRef.value) {
|
||||
options.mjpegVideoRef.value.src = ''
|
||||
}
|
||||
|
||||
videoLoading.value = true
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const success = await webrtc.connect()
|
||||
if (success) {
|
||||
toast.success(t('console.webrtcConnected'), {
|
||||
description: t('console.webrtcConnectedDesc'),
|
||||
duration: 3000,
|
||||
})
|
||||
|
||||
if (webrtc.videoTrack.value && options.webrtcVideoRef.value) {
|
||||
const stream = webrtc.getMediaStream()
|
||||
if (stream) {
|
||||
options.webrtcVideoRef.value.srcObject = stream
|
||||
try {
|
||||
await options.webrtcVideoRef.value.play()
|
||||
} catch {
|
||||
// AbortError expected when switching modes quickly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
videoLoading.value = false
|
||||
videoMode.value = codec
|
||||
unifiedAudio.switchMode('webrtc')
|
||||
} else {
|
||||
throw new Error('WebRTC connection failed')
|
||||
}
|
||||
} catch {
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = 'WebRTC connection failed'
|
||||
videoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
clearRetryTimers()
|
||||
retryCount = 0
|
||||
consecutiveErrors = 0
|
||||
mjpegTimestamp.value = 0
|
||||
|
||||
if (options.mjpegVideoRef.value) {
|
||||
options.mjpegVideoRef.value.src = ''
|
||||
}
|
||||
|
||||
videoLoading.value = true
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
if (webrtc.isConnected.value || webrtc.sessionId.value) {
|
||||
await webrtc.disconnect()
|
||||
}
|
||||
|
||||
await streamApi.setMode(codec)
|
||||
const success = await webrtc.connect()
|
||||
|
||||
if (success) {
|
||||
toast.success(t('console.webrtcConnected'), {
|
||||
description: t('console.webrtcConnectedDesc'),
|
||||
duration: 3000,
|
||||
})
|
||||
|
||||
if (webrtc.videoTrack.value && options.webrtcVideoRef.value) {
|
||||
const stream = webrtc.getMediaStream()
|
||||
if (stream) {
|
||||
options.webrtcVideoRef.value.srcObject = stream
|
||||
try {
|
||||
await options.webrtcVideoRef.value.play()
|
||||
} catch {
|
||||
// AbortError expected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
videoLoading.value = false
|
||||
unifiedAudio.switchMode('webrtc')
|
||||
} else {
|
||||
throw new Error('WebRTC connection failed')
|
||||
}
|
||||
} catch {
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = t('console.webrtcFailed')
|
||||
videoLoading.value = false
|
||||
|
||||
toast.error(t('console.webrtcFailed'), {
|
||||
description: t('console.fallingBackToMjpeg'),
|
||||
duration: 5000,
|
||||
})
|
||||
videoMode.value = 'mjpeg'
|
||||
}
|
||||
}
|
||||
|
||||
async function switchToMJPEG() {
|
||||
videoLoading.value = true
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
await streamApi.setMode('mjpeg')
|
||||
} catch {
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
if (webrtc.isConnected.value) {
|
||||
webrtc.disconnect()
|
||||
}
|
||||
|
||||
if (options.webrtcVideoRef.value) {
|
||||
options.webrtcVideoRef.value.srcObject = null
|
||||
}
|
||||
|
||||
unifiedAudio.switchMode('ws')
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
function handleModeChange(mode: VideoMode) {
|
||||
if (mode === videoMode.value) return
|
||||
|
||||
if (mode !== 'mjpeg') {
|
||||
mjpegTimestamp.value = 0
|
||||
}
|
||||
|
||||
videoMode.value = mode
|
||||
localStorage.setItem('videoMode', mode)
|
||||
|
||||
if (mode !== 'mjpeg') {
|
||||
switchToWebRTC(mode)
|
||||
} else {
|
||||
switchToMJPEG()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stream config events
|
||||
function handleStreamConfigChanging(data: { reason?: string }) {
|
||||
function handleStreamConfigChanging() {
|
||||
clearRetryTimers()
|
||||
videoRestarting.value = true
|
||||
videoLoading.value = true
|
||||
@@ -297,14 +130,9 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
retryCount = 0
|
||||
consecutiveErrors = 0
|
||||
backendFps.value = 0
|
||||
|
||||
toast.info(t('console.videoRestarting'), {
|
||||
description: data.reason === 'device_switch' ? t('console.deviceSwitching') : t('console.configChanging'),
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
function handleStreamConfigApplied(data: { device: string; resolution: [number, number]; fps: number }) {
|
||||
function handleStreamConfigApplied() {
|
||||
consecutiveErrors = 0
|
||||
|
||||
gracePeriodTimeoutId = window.setTimeout(() => {
|
||||
@@ -313,17 +141,7 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
}, GRACE_PERIOD)
|
||||
|
||||
videoRestarting.value = false
|
||||
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
switchToWebRTC(videoMode.value)
|
||||
} else {
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
toast.success(t('console.videoRestarted'), {
|
||||
description: `${data.device} - ${data.resolution[0]}x${data.resolution[1]} @ ${data.fps}fps`,
|
||||
duration: 3000,
|
||||
})
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
function handleStreamStatsUpdate(data: { clients?: number; clients_stat?: Record<string, { fps: number }> }) {
|
||||
@@ -331,13 +149,6 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
systemStore.updateStreamClients(data.clients)
|
||||
}
|
||||
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
if (data.clients_stat) {
|
||||
clientsStats.value = data.clients_stat as any
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (data.clients_stat) {
|
||||
clientsStats.value = data.clients_stat as any
|
||||
const myStats = data.clients_stat[clientId]
|
||||
@@ -359,118 +170,27 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
|
||||
if (data.video?.config_changing) return
|
||||
|
||||
if (data.video?.stream_mode) {
|
||||
const serverStreamMode = data.video.stream_mode
|
||||
const serverMode = serverStreamMode === 'webrtc' ? 'h264' : serverStreamMode as VideoMode
|
||||
|
||||
if (!initialDeviceInfoReceived) {
|
||||
initialDeviceInfoReceived = true
|
||||
|
||||
if (serverMode !== videoMode.value) {
|
||||
videoMode.value = serverMode
|
||||
if (serverMode !== 'mjpeg') {
|
||||
setTimeout(() => connectWebRTCOnly(serverMode), 100)
|
||||
} else {
|
||||
setTimeout(() => refreshVideo(), 100)
|
||||
}
|
||||
} else if (serverMode !== 'mjpeg') {
|
||||
setTimeout(() => connectWebRTCOnly(serverMode), 100)
|
||||
} else {
|
||||
setTimeout(() => refreshVideo(), 100)
|
||||
}
|
||||
} else if (serverMode !== videoMode.value) {
|
||||
handleModeChange(serverMode)
|
||||
if (!initialDeviceInfoReceived) {
|
||||
initialDeviceInfoReceived = true
|
||||
if (data.video?.stream_mode === 'mjpeg') {
|
||||
setTimeout(() => refreshVideo(), 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamModeChanged(data: { mode: string; previous_mode: string }) {
|
||||
const newMode = data.mode === 'webrtc' ? 'h264' : data.mode as VideoMode
|
||||
|
||||
toast.info(t('console.streamModeChanged'), {
|
||||
description: t('console.streamModeChangedDesc', { mode: data.mode.toUpperCase() }),
|
||||
duration: 5000,
|
||||
})
|
||||
|
||||
if (newMode !== videoMode.value) {
|
||||
handleModeChange(newMode)
|
||||
}
|
||||
function handleModeChange(mode: VideoMode) {
|
||||
if (mode !== 'mjpeg') return
|
||||
if (mode === videoMode.value) return
|
||||
videoMode.value = mode
|
||||
localStorage.setItem('videoMode', mode)
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
// Watch WebRTC video track
|
||||
watch(() => webrtc.videoTrack.value, async (track) => {
|
||||
if (track && options.webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
|
||||
const stream = webrtc.getMediaStream()
|
||||
if (stream) {
|
||||
options.webrtcVideoRef.value.srcObject = stream
|
||||
try {
|
||||
await options.webrtcVideoRef.value.play()
|
||||
} catch {
|
||||
// AbortError expected
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch WebRTC audio track
|
||||
watch(() => webrtc.audioTrack.value, (track) => {
|
||||
if (track && options.webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
|
||||
const currentStream = options.webrtcVideoRef.value.srcObject as MediaStream | null
|
||||
if (currentStream && currentStream.getAudioTracks().length === 0) {
|
||||
currentStream.addTrack(track)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch WebRTC element for unified audio
|
||||
watch(options.webrtcVideoRef, (el) => {
|
||||
unifiedAudio.setWebRTCElement(el)
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch WebRTC stats for FPS
|
||||
watch(webrtc.stats, (stats) => {
|
||||
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
|
||||
backendFps.value = Math.round(stats.framesPerSecond)
|
||||
systemStore.setStreamOnline(true)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Watch WebRTC state for auto-reconnect
|
||||
watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
if (newState === 'connected') {
|
||||
systemStore.setStreamOnline(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (webrtcReconnectTimeout) {
|
||||
clearTimeout(webrtcReconnectTimeout)
|
||||
webrtcReconnectTimeout = null
|
||||
}
|
||||
|
||||
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
|
||||
webrtcReconnectTimeout = setTimeout(async () => {
|
||||
if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') {
|
||||
try {
|
||||
await webrtc.connect()
|
||||
} catch {
|
||||
// Will retry on next disconnect
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
function cleanup() {
|
||||
clearRetryTimers()
|
||||
if (webrtcReconnectTimeout) {
|
||||
clearTimeout(webrtcReconnectTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
const state: VideoStreamState = {
|
||||
mode: videoMode,
|
||||
loading: videoLoading,
|
||||
error: videoError,
|
||||
@@ -479,29 +199,19 @@ export function useVideoStream(options: UseVideoStreamOptions) {
|
||||
fps: backendFps,
|
||||
mjpegUrl,
|
||||
clientId,
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
clientsStats,
|
||||
isWebRTCMode,
|
||||
|
||||
// WebRTC access
|
||||
webrtc,
|
||||
|
||||
// Methods
|
||||
refreshVideo,
|
||||
handleVideoLoad,
|
||||
handleVideoError,
|
||||
handleModeChange,
|
||||
connectWebRTCOnly,
|
||||
switchToWebRTC,
|
||||
switchToMJPEG,
|
||||
|
||||
// Event handlers
|
||||
handleStreamConfigChanging,
|
||||
handleStreamConfigApplied,
|
||||
handleStreamStatsUpdate,
|
||||
handleDeviceInfo,
|
||||
handleStreamModeChanged,
|
||||
|
||||
// Cleanup
|
||||
handleModeChange,
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useRouter } from 'vue-router'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { useConsoleEvents } from '@/composables/useConsoleEvents'
|
||||
import { useHidWebSocket } from '@/composables/useHidWebSocket'
|
||||
import { useWebRTC } from '@/composables/useWebRTC'
|
||||
import { useVideoSession } from '@/composables/useVideoSession'
|
||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, userApi } from '@/api'
|
||||
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
||||
@@ -58,10 +60,26 @@ const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const systemStore = useSystemStore()
|
||||
const authStore = useAuthStore()
|
||||
const { on, off, connect, connected: wsConnected, networkError: wsNetworkError } = useWebSocket()
|
||||
const { connected: wsConnected, networkError: wsNetworkError } = useWebSocket()
|
||||
const hidWs = useHidWebSocket()
|
||||
const webrtc = useWebRTC()
|
||||
const unifiedAudio = getUnifiedAudio()
|
||||
const videoSession = useVideoSession()
|
||||
|
||||
const consoleEvents = useConsoleEvents({
|
||||
onStreamConfigChanging: handleStreamConfigChanging,
|
||||
onStreamConfigApplied: handleStreamConfigApplied,
|
||||
onStreamStatsUpdate: handleStreamStatsUpdate,
|
||||
onStreamModeChanged: handleStreamModeChanged,
|
||||
onStreamModeSwitching: handleStreamModeSwitching,
|
||||
onStreamModeReady: handleStreamModeReady,
|
||||
onWebRTCReady: handleWebRTCReady,
|
||||
onStreamStateChanged: handleStreamStateChanged,
|
||||
onStreamDeviceLost: handleStreamDeviceLost,
|
||||
onStreamRecovered: handleStreamRecovered,
|
||||
onDeviceInfo: handleDeviceInfo,
|
||||
onAudioStateChanged: handleAudioStateChanged,
|
||||
})
|
||||
|
||||
// Video mode state
|
||||
const videoMode = ref<VideoMode>('mjpeg')
|
||||
@@ -386,6 +404,74 @@ const BASE_RETRY_DELAY = 2000
|
||||
const GRACE_PERIOD = 2000 // Ignore errors for 2s after config change (reduced from 3s)
|
||||
const MAX_CONSECUTIVE_ERRORS = 2 // If 2+ errors in grace period, it's a real problem
|
||||
|
||||
// Last-frame overlay (prevents black flash during mode switches)
|
||||
const frameOverlayUrl = ref<string | null>(null)
|
||||
|
||||
function clearFrameOverlay() {
|
||||
frameOverlayUrl.value = null
|
||||
}
|
||||
|
||||
async function captureFrameOverlay() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const MAX_WIDTH = 1280
|
||||
|
||||
if (videoMode.value === 'mjpeg') {
|
||||
const img = videoRef.value
|
||||
if (!img || !img.naturalWidth || !img.naturalHeight) return
|
||||
|
||||
const scale = Math.min(1, MAX_WIDTH / img.naturalWidth)
|
||||
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale))
|
||||
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale))
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
} else {
|
||||
const video = webrtcVideoRef.value
|
||||
if (!video || !video.videoWidth || !video.videoHeight) return
|
||||
|
||||
const scale = Math.min(1, MAX_WIDTH / video.videoWidth)
|
||||
canvas.width = Math.max(1, Math.round(video.videoWidth * scale))
|
||||
canvas.height = Math.max(1, Math.round(video.videoHeight * scale))
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
// Use JPEG to keep memory reasonable
|
||||
frameOverlayUrl.value = canvas.toDataURL('image/jpeg', 0.7)
|
||||
} catch {
|
||||
// Best-effort only
|
||||
}
|
||||
}
|
||||
|
||||
function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false
|
||||
|
||||
const cleanup = () => {
|
||||
el.removeEventListener('loadeddata', onReady)
|
||||
el.removeEventListener('playing', onReady)
|
||||
}
|
||||
|
||||
const onReady = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
el.addEventListener('loadeddata', onReady)
|
||||
el.addEventListener('playing', onReady)
|
||||
|
||||
setTimeout(() => {
|
||||
if (done) return
|
||||
done = true
|
||||
cleanup()
|
||||
resolve(false)
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
function handleVideoLoad() {
|
||||
// MJPEG video frame loaded successfully - update stream online status
|
||||
// This fixes the timing issue where device_info event may arrive before stream is fully active
|
||||
@@ -420,6 +506,7 @@ function handleVideoLoad() {
|
||||
videoRestarting.value = false
|
||||
retryCount = 0
|
||||
consecutiveErrors = 0
|
||||
clearFrameOverlay()
|
||||
|
||||
// Auto-focus video container for immediate keyboard input
|
||||
const container = videoContainerRef.value
|
||||
@@ -478,158 +565,20 @@ function handleVideoError() {
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// WebSocket event handlers
|
||||
function handleHidStateChanged(_data: any) {
|
||||
// Empty handler to prevent "No handler for event: hid.state_changed" warning
|
||||
// HID state changes are handled via system.device_info event
|
||||
}
|
||||
|
||||
// HID device monitoring handlers
|
||||
function handleHidDeviceLost(data: { backend: string; device?: string; reason: string; error_code: string }) {
|
||||
// Don't treat temporary errors (EAGAIN) as device lost
|
||||
// These are just temporary busy states that will recover automatically
|
||||
const temporaryErrors = ['eagain', 'eagain_retry']
|
||||
if (temporaryErrors.includes(data.error_code)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update system store HID state for actual device loss
|
||||
if (systemStore.hid) {
|
||||
systemStore.hid.initialized = false
|
||||
}
|
||||
// Show error toast
|
||||
toast.error(t('hid.deviceLost'), {
|
||||
description: t('hid.deviceLostDesc', { backend: data.backend, reason: data.reason }),
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
function handleHidReconnecting(data: { backend: string; attempt: number }) {
|
||||
// Only show toast every 5 attempts to avoid spam
|
||||
if (data.attempt === 1 || data.attempt % 5 === 0) {
|
||||
toast.info(t('hid.reconnecting'), {
|
||||
description: t('hid.reconnectingDesc', { attempt: data.attempt }),
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleHidRecovered(data: { backend: string }) {
|
||||
// Update system store HID state
|
||||
if (systemStore.hid) {
|
||||
systemStore.hid.initialized = true
|
||||
}
|
||||
// Show success toast
|
||||
toast.success(t('hid.recovered'), {
|
||||
description: t('hid.recoveredDesc', { backend: data.backend }),
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
|
||||
// Stream device monitoring handlers
|
||||
// Stream device monitoring handlers (UI-only; notifications/state are handled by useConsoleEvents)
|
||||
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||
// Update video state
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
|
||||
// Update system store
|
||||
if (systemStore.stream) {
|
||||
systemStore.stream.online = false
|
||||
}
|
||||
// Show error toast
|
||||
toast.error(t('console.deviceLost'), {
|
||||
description: t('console.deviceLostDesc', { device: data.device, reason: data.reason }),
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
function handleStreamReconnecting(data: { device: string; attempt: number }) {
|
||||
// Only show toast every 5 attempts to avoid spam
|
||||
if (data.attempt === 1 || data.attempt % 5 === 0) {
|
||||
toast.info(t('console.deviceRecovering'), {
|
||||
description: t('console.deviceRecoveringDesc', { attempt: data.attempt }),
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleStreamRecovered(_data: { device: string }) {
|
||||
// Reset video error state
|
||||
videoError.value = false
|
||||
videoErrorMessage.value = ''
|
||||
// Update system store
|
||||
if (systemStore.stream) {
|
||||
systemStore.stream.online = true
|
||||
}
|
||||
// Show success toast
|
||||
toast.success(t('console.deviceRecovered'), {
|
||||
description: t('console.deviceRecoveredDesc'),
|
||||
duration: 3000,
|
||||
})
|
||||
// Refresh video stream
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
// Audio device monitoring handlers
|
||||
function handleAudioDeviceLost(data: { device?: string; reason: string; error_code: string }) {
|
||||
// Update system store audio state
|
||||
if (systemStore.audio) {
|
||||
systemStore.audio.streaming = false
|
||||
systemStore.audio.error = data.reason
|
||||
}
|
||||
// Show error toast
|
||||
toast.error(t('audio.deviceLost'), {
|
||||
description: t('audio.deviceLostDesc', { device: data.device || 'default', reason: data.reason }),
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
function handleAudioReconnecting(data: { attempt: number }) {
|
||||
// Only show toast every 5 attempts to avoid spam
|
||||
if (data.attempt === 1 || data.attempt % 5 === 0) {
|
||||
toast.info(t('audio.reconnecting'), {
|
||||
description: t('audio.reconnectingDesc', { attempt: data.attempt }),
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioRecovered(data: { device?: string }) {
|
||||
// Update system store audio state
|
||||
if (systemStore.audio) {
|
||||
systemStore.audio.error = null
|
||||
}
|
||||
// Show success toast
|
||||
toast.success(t('audio.recovered'), {
|
||||
description: t('audio.recoveredDesc', { device: data.device || 'default' }),
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
|
||||
// MSD device monitoring handlers
|
||||
function handleMsdError(data: { reason: string; error_code: string }) {
|
||||
// Update system store MSD state
|
||||
if (systemStore.msd) {
|
||||
systemStore.msd.error = data.reason
|
||||
}
|
||||
// Show error toast
|
||||
toast.error(t('msd.error'), {
|
||||
description: t('msd.errorDesc', { reason: data.reason }),
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
function handleMsdRecovered() {
|
||||
// Update system store MSD state
|
||||
if (systemStore.msd) {
|
||||
systemStore.msd.error = null
|
||||
}
|
||||
// Show success toast
|
||||
toast.success(t('msd.recovered'), {
|
||||
description: t('msd.recoveredDesc'),
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleAudioStateChanged(data: { streaming: boolean; device: string | null }) {
|
||||
if (!data.streaming) {
|
||||
// Audio stopped, disconnect
|
||||
@@ -663,31 +612,6 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
|
||||
await unifiedAudio.connect()
|
||||
}
|
||||
|
||||
// MSD WebSocket event handlers
|
||||
function handleMsdStateChanged(_data: { mode: string; connected: boolean }) {
|
||||
// Update MSD state in store (will be reflected in MSD components)
|
||||
systemStore.fetchMsdState().catch(() => null)
|
||||
}
|
||||
|
||||
function handleMsdImageMounted(data: { image_id: string; image_name: string; size: number; cdrom: boolean }) {
|
||||
// Show success notification
|
||||
toast.success(t('msd.imageMounted', { name: data.image_name }), {
|
||||
description: `${(data.size / 1024 / 1024).toFixed(2)} MB - ${data.cdrom ? 'CD-ROM' : 'Disk'}`,
|
||||
duration: 3000,
|
||||
})
|
||||
// Refresh MSD state
|
||||
systemStore.fetchMsdState().catch(() => null)
|
||||
}
|
||||
|
||||
function handleMsdImageUnmounted() {
|
||||
// Show info notification
|
||||
toast.info(t('msd.imageUnmounted'), {
|
||||
duration: 2000,
|
||||
})
|
||||
// Refresh MSD state
|
||||
systemStore.fetchMsdState().catch(() => null)
|
||||
}
|
||||
|
||||
function handleStreamConfigChanging(data: any) {
|
||||
// Clear any existing retries and grace periods
|
||||
if (retryTimeoutId !== null) {
|
||||
@@ -749,14 +673,24 @@ function handleStreamConfigApplied(data: any) {
|
||||
}
|
||||
|
||||
// 处理 WebRTC 就绪事件 - 这是后端真正准备好接受 WebRTC 连接的信号
|
||||
function handleWebRTCReady(data: { codec: string; hardware: boolean }) {
|
||||
console.log(`[WebRTCReady] Backend ready: codec=${data.codec}, hardware=${data.hardware}`)
|
||||
function handleWebRTCReady(data: { codec: string; hardware: boolean; transition_id?: string }) {
|
||||
console.log(`[WebRTCReady] Backend ready: codec=${data.codec}, hardware=${data.hardware}, transition_id=${data.transition_id || '-'}`)
|
||||
videoSession.onWebRTCReady(data)
|
||||
}
|
||||
|
||||
// 如果正在进行模式切换,标记后端已就绪
|
||||
if (isModeSwitching.value) {
|
||||
console.log('[WebRTCReady] Signaling backend ready for WebRTC connection')
|
||||
backendReadyForWebRTC = true
|
||||
function handleStreamModeReady(data: { transition_id: string; mode: string }) {
|
||||
videoSession.onModeReady(data)
|
||||
videoRestarting.value = false
|
||||
}
|
||||
|
||||
function handleStreamModeSwitching(data: { transition_id: string; to_mode: string; from_mode: string }) {
|
||||
// External mode switches: keep UI responsive and avoid black flash
|
||||
if (!isModeSwitching.value) {
|
||||
videoRestarting.value = true
|
||||
videoLoading.value = true
|
||||
captureFrameOverlay().catch(() => {})
|
||||
}
|
||||
videoSession.onModeSwitching(data)
|
||||
}
|
||||
|
||||
function handleStreamStateChanged(data: any) {
|
||||
@@ -836,8 +770,8 @@ function handleDeviceInfo(data: any) {
|
||||
setTimeout(() => refreshVideo(), 100)
|
||||
}
|
||||
} else if (serverMode !== videoMode.value) {
|
||||
// Subsequent device_info with mode change - sync to server
|
||||
handleVideoModeChange(serverMode as VideoMode)
|
||||
// Subsequent device_info with mode change - sync to server (no setMode)
|
||||
syncToServerMode(serverMode as VideoMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -859,18 +793,16 @@ function handleStreamModeChanged(data: { mode: string; previous_mode: string })
|
||||
duration: 5000,
|
||||
})
|
||||
|
||||
// Switch to new mode
|
||||
// Switch to new mode (external sync handled by device_info after mode_ready)
|
||||
if (newMode !== videoMode.value) {
|
||||
handleVideoModeChange(newMode as VideoMode)
|
||||
syncToServerMode(newMode as VideoMode)
|
||||
}
|
||||
}
|
||||
|
||||
// 标记是否正在刷新视频(用于忽略清空 src 时触发的 error 事件)
|
||||
let isRefreshingVideo = false
|
||||
// 标记是否正在切换模式(防止竞态条件和 503 错误)
|
||||
const isModeSwitching = ref(false)
|
||||
// 标记后端是否已准备好接受 WebRTC 连接(由 StreamConfigApplied 事件设置)
|
||||
let backendReadyForWebRTC = false
|
||||
const isModeSwitching = videoSession.localSwitching
|
||||
|
||||
function reloadPage() {
|
||||
window.location.reload()
|
||||
@@ -954,9 +886,7 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
|
||||
throw new Error('WebRTC connection failed')
|
||||
}
|
||||
} catch {
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = 'WebRTC connection failed'
|
||||
videoLoading.value = false
|
||||
await fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,6 +908,8 @@ async function rebindWebRTCVideo() {
|
||||
} catch {
|
||||
// AbortError is expected when switching modes quickly, ignore it
|
||||
}
|
||||
await waitForVideoFirstFrame(webrtcVideoRef.value, 2000)
|
||||
clearFrameOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1015,28 +947,27 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
}
|
||||
|
||||
// Step 2: Call backend API to switch mode with specific codec
|
||||
// 重置就绪标志
|
||||
backendReadyForWebRTC = false
|
||||
await streamApi.setMode(codec)
|
||||
const modeResp = await streamApi.setMode(codec)
|
||||
if (modeResp.transition_id) {
|
||||
videoSession.registerTransition(modeResp.transition_id)
|
||||
const [mode, webrtcReady] = await Promise.all([
|
||||
videoSession.waitForModeReady(modeResp.transition_id, 5000),
|
||||
videoSession.waitForWebRTCReady(modeResp.transition_id, 3000),
|
||||
])
|
||||
|
||||
// Step 3: 等待后端完成格式切换(由 StreamConfigApplied 事件触发)
|
||||
// 后端需要时间来:停止捕获 → 切换格式 → 重启捕获 → 连接 frame source
|
||||
// 使用轮询等待,最多等待 3 秒
|
||||
const maxWaitTime = 3000
|
||||
const pollInterval = 100
|
||||
let waited = 0
|
||||
while (!backendReadyForWebRTC && waited < maxWaitTime) {
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
waited += pollInterval
|
||||
if (mode && mode !== codec && mode !== 'webrtc') {
|
||||
console.warn(`[WebRTC] Backend mode_ready returned '${mode}', expected '${codec}', falling back`)
|
||||
throw new Error(`Backend switched to unexpected mode: ${mode}`)
|
||||
}
|
||||
|
||||
if (!webrtcReady) {
|
||||
console.warn('[WebRTC] Backend not ready after timeout, attempting connection anyway')
|
||||
} else {
|
||||
console.log('[WebRTC] Backend ready signal received, connecting')
|
||||
}
|
||||
}
|
||||
|
||||
if (!backendReadyForWebRTC) {
|
||||
console.warn('[WebRTC] Backend not ready after timeout, attempting connection anyway')
|
||||
} else {
|
||||
console.log('[WebRTC] Backend ready signal received, connecting')
|
||||
}
|
||||
|
||||
// Step 4: Connect WebRTC with retry
|
||||
// Step 3: Connect WebRTC with retry
|
||||
let retries = 3
|
||||
let success = false
|
||||
while (retries > 0 && !success) {
|
||||
@@ -1066,16 +997,30 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
||||
throw new Error('WebRTC connection failed')
|
||||
}
|
||||
} catch {
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = t('console.webrtcFailed')
|
||||
videoLoading.value = false
|
||||
await fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg'), true)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to MJPEG
|
||||
toast.error(t('console.webrtcFailed'), {
|
||||
description: t('console.fallingBackToMjpeg'),
|
||||
duration: 5000,
|
||||
})
|
||||
videoMode.value = 'mjpeg'
|
||||
async function fallbackToMJPEG(reason: string, description?: string, force = false) {
|
||||
if (fallbackInProgress) return
|
||||
if (videoMode.value === 'mjpeg') return
|
||||
if (!force && (videoSession.localSwitching.value || videoSession.backendSwitching.value)) return
|
||||
|
||||
fallbackInProgress = true
|
||||
videoError.value = true
|
||||
videoErrorMessage.value = reason
|
||||
videoLoading.value = false
|
||||
|
||||
toast.error(reason, {
|
||||
description: description ?? '',
|
||||
duration: 5000,
|
||||
})
|
||||
|
||||
videoMode.value = 'mjpeg'
|
||||
try {
|
||||
await switchToMJPEG()
|
||||
} finally {
|
||||
fallbackInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,7 +1032,14 @@ async function switchToMJPEG() {
|
||||
// Step 1: Call backend API to switch mode FIRST
|
||||
// This ensures the MJPEG endpoint will accept our request
|
||||
try {
|
||||
await streamApi.setMode('mjpeg')
|
||||
const modeResp = await streamApi.setMode('mjpeg')
|
||||
if (modeResp.transition_id) {
|
||||
videoSession.registerTransition(modeResp.transition_id)
|
||||
const mode = await videoSession.waitForModeReady(modeResp.transition_id, 5000)
|
||||
if (mode && mode !== 'mjpeg') {
|
||||
console.warn(`[MJPEG] Backend mode_ready returned '${mode}', expected 'mjpeg'`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to switch to MJPEG mode:', e)
|
||||
// Continue anyway - the mode might already be correct
|
||||
@@ -1110,18 +1062,32 @@ async function switchToMJPEG() {
|
||||
refreshVideo()
|
||||
}
|
||||
|
||||
function syncToServerMode(mode: VideoMode) {
|
||||
if (videoSession.localSwitching.value || videoSession.backendSwitching.value) return
|
||||
if (mode === videoMode.value) return
|
||||
|
||||
videoMode.value = mode
|
||||
localStorage.setItem('videoMode', mode)
|
||||
|
||||
if (mode !== 'mjpeg') {
|
||||
connectWebRTCOnly(mode)
|
||||
} else {
|
||||
refreshVideo()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle video mode change
|
||||
async function handleVideoModeChange(mode: VideoMode) {
|
||||
// 防止重复切换和竞态条件
|
||||
if (mode === videoMode.value) return
|
||||
if (isModeSwitching.value) {
|
||||
console.log('[VideoMode] Switch already in progress, ignoring')
|
||||
if (!videoSession.tryStartLocalSwitch()) {
|
||||
console.log('[VideoMode] Switch throttled or in progress, ignoring')
|
||||
return
|
||||
}
|
||||
|
||||
isModeSwitching.value = true
|
||||
|
||||
try {
|
||||
await captureFrameOverlay()
|
||||
|
||||
// Reset mjpegTimestamp to 0 when switching away from MJPEG
|
||||
// This prevents mjpegUrl from returning a valid URL and stops MJPEG requests
|
||||
if (mode !== 'mjpeg') {
|
||||
@@ -1145,7 +1111,7 @@ async function handleVideoModeChange(mode: VideoMode) {
|
||||
await switchToMJPEG()
|
||||
}
|
||||
} finally {
|
||||
isModeSwitching.value = false
|
||||
videoSession.endLocalSwitch()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1190,6 +1156,8 @@ watch(webrtc.stats, (stats) => {
|
||||
|
||||
// Watch for WebRTC connection state changes - auto-reconnect on disconnect
|
||||
let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let webrtcReconnectFailures = 0
|
||||
let fallbackInProgress = false
|
||||
watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
console.log('[WebRTC] State changed:', oldState, '->', newState)
|
||||
|
||||
@@ -1197,6 +1165,7 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
if (newState === 'connected') {
|
||||
systemStore.setStreamOnline(true)
|
||||
webrtcReconnectFailures = 0
|
||||
} else if (newState === 'disconnected' || newState === 'failed') {
|
||||
// Don't immediately set offline - wait for potential reconnect
|
||||
// The device_info event will eventually sync the correct state
|
||||
@@ -1214,13 +1183,29 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
||||
webrtcReconnectTimeout = setTimeout(async () => {
|
||||
if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') {
|
||||
try {
|
||||
await webrtc.connect()
|
||||
const success = await webrtc.connect()
|
||||
if (!success) {
|
||||
webrtcReconnectFailures += 1
|
||||
if (webrtcReconnectFailures >= 2) {
|
||||
await fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg'))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Reconnect failed, will retry on next disconnect
|
||||
webrtcReconnectFailures += 1
|
||||
if (webrtcReconnectFailures >= 2) {
|
||||
await fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
|
||||
webrtcReconnectFailures += 1
|
||||
if (webrtcReconnectFailures >= 1) {
|
||||
fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg')).catch(() => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function toggleFullscreen() {
|
||||
@@ -1716,33 +1701,8 @@ function handleToggleMouseMode() {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// 1. 先注册 WebSocket 事件监听器
|
||||
on('stream.config_changing', handleStreamConfigChanging)
|
||||
on('stream.config_applied', handleStreamConfigApplied)
|
||||
on('stream.webrtc_ready', handleWebRTCReady)
|
||||
on('stream.state_changed', handleStreamStateChanged)
|
||||
on('stream.stats_update', handleStreamStatsUpdate)
|
||||
on('stream.mode_changed', handleStreamModeChanged)
|
||||
on('system.device_info', handleDeviceInfo)
|
||||
on('hid.state_changed', handleHidStateChanged)
|
||||
on('hid.device_lost', handleHidDeviceLost)
|
||||
on('hid.reconnecting', handleHidReconnecting)
|
||||
on('hid.recovered', handleHidRecovered)
|
||||
on('audio.state_changed', handleAudioStateChanged)
|
||||
on('msd.state_changed', handleMsdStateChanged)
|
||||
on('msd.image_mounted', handleMsdImageMounted)
|
||||
on('msd.image_unmounted', handleMsdImageUnmounted)
|
||||
on('stream.device_lost', handleStreamDeviceLost)
|
||||
on('stream.reconnecting', handleStreamReconnecting)
|
||||
on('stream.recovered', handleStreamRecovered)
|
||||
on('audio.device_lost', handleAudioDeviceLost)
|
||||
on('audio.reconnecting', handleAudioReconnecting)
|
||||
on('audio.recovered', handleAudioRecovered)
|
||||
on('msd.error', handleMsdError)
|
||||
on('msd.recovered', handleMsdRecovered)
|
||||
|
||||
// 2. 再连接 WebSocket (会触发 subscribe → device_info)
|
||||
connect()
|
||||
// 1. 先订阅 WebSocket 事件,再连接(内部会 connect)
|
||||
consoleEvents.subscribe()
|
||||
|
||||
// 3. Watch WebSocket connection states and sync to store
|
||||
watch([wsConnected, wsNetworkError], ([connected, netError], [_prevConnected, prevNetError]) => {
|
||||
@@ -1818,34 +1778,12 @@ onUnmounted(() => {
|
||||
clearTimeout(gracePeriodTimeoutId)
|
||||
gracePeriodTimeoutId = null
|
||||
}
|
||||
videoSession.clearWaiters()
|
||||
|
||||
// Reset counters
|
||||
retryCount = 0
|
||||
|
||||
// Unregister WebSocket event handlers
|
||||
off('stream.config_changing', handleStreamConfigChanging)
|
||||
off('stream.config_applied', handleStreamConfigApplied)
|
||||
off('stream.webrtc_ready', handleWebRTCReady)
|
||||
off('stream.state_changed', handleStreamStateChanged)
|
||||
off('stream.stats_update', handleStreamStatsUpdate)
|
||||
off('stream.mode_changed', handleStreamModeChanged)
|
||||
off('system.device_info', handleDeviceInfo)
|
||||
off('hid.state_changed', handleHidStateChanged)
|
||||
off('hid.device_lost', handleHidDeviceLost)
|
||||
off('hid.reconnecting', handleHidReconnecting)
|
||||
off('hid.recovered', handleHidRecovered)
|
||||
off('audio.state_changed', handleAudioStateChanged)
|
||||
off('msd.state_changed', handleMsdStateChanged)
|
||||
off('msd.image_mounted', handleMsdImageMounted)
|
||||
off('msd.image_unmounted', handleMsdImageUnmounted)
|
||||
off('stream.device_lost', handleStreamDeviceLost)
|
||||
off('stream.reconnecting', handleStreamReconnecting)
|
||||
off('stream.recovered', handleStreamRecovered)
|
||||
off('audio.device_lost', handleAudioDeviceLost)
|
||||
off('audio.reconnecting', handleAudioReconnecting)
|
||||
off('audio.recovered', handleAudioRecovered)
|
||||
off('msd.error', handleMsdError)
|
||||
off('msd.recovered', handleMsdRecovered)
|
||||
consoleEvents.unsubscribe()
|
||||
consecutiveErrors = 0
|
||||
|
||||
// Disconnect WebRTC if connected
|
||||
@@ -1856,17 +1794,6 @@ onUnmounted(() => {
|
||||
// Exit pointer lock if active
|
||||
exitPointerLock()
|
||||
|
||||
// Remove WebSocket event listeners
|
||||
off('stream.config_changing', handleStreamConfigChanging)
|
||||
off('stream.config_applied', handleStreamConfigApplied)
|
||||
off('stream.webrtc_ready', handleWebRTCReady)
|
||||
off('stream.state_changed', handleStreamStateChanged)
|
||||
off('stream.stats_update', handleStreamStatsUpdate)
|
||||
off('stream.mode_changed', handleStreamModeChanged)
|
||||
off('system.device_info', handleDeviceInfo)
|
||||
off('hid.state_changed', handleHidStateChanged)
|
||||
off('audio.state_changed', handleAudioStateChanged)
|
||||
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', handleBlur)
|
||||
@@ -2058,6 +1985,14 @@ onUnmounted(() => {
|
||||
playsinline
|
||||
/>
|
||||
|
||||
<!-- Last-frame overlay (reduces black flash when switching modes) -->
|
||||
<img
|
||||
v-if="frameOverlayUrl"
|
||||
:src="frameOverlayUrl"
|
||||
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<!-- Loading Overlay with smooth transition and visual feedback -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user