Files
One-KVM/web/src/views/ConsoleView.vue

2921 lines
95 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
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, atxConfigApi, authApi } from '@/api'
import { CanonicalKey, HidBackend } from '@/types/generated'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils'
import { formatFpsValue } from '@/lib/fps'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
// Components
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
import ActionBar from '@/components/ActionBar.vue'
import InfoBar from '@/components/InfoBar.vue'
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
import StatsSheet from '@/components/StatsSheet.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
MonitorOff,
RefreshCw,
LogOut,
Sun,
Moon,
ChevronDown,
Terminal,
ExternalLink,
KeyRound,
Loader2,
} from 'lucide-vue-next'
const { t, te } = useI18n()
const router = useRouter()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
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,
})
// Video mode state
const videoMode = ref<VideoMode>('mjpeg')
// Video state
const videoRef = ref<HTMLImageElement | null>(null)
const webrtcVideoRef = ref<HTMLVideoElement | null>(null)
const videoContainerRef = ref<HTMLDivElement | null>(null)
const isFullscreen = ref(false)
const videoLoading = ref(true)
const videoError = ref(false)
const videoErrorMessage = ref('')
const videoRestarting = ref(false) // Track if video is restarting due to config change
const mjpegFrameReceived = ref(false) // Whether MJPEG stream has received at least one frame
/** From `stream.state_changed`: ok | no_signal | device_lost | device_busy */
type StreamSignalState = 'ok' | 'no_signal' | 'device_lost' | 'device_busy'
const streamSignalState = ref<StreamSignalState>('ok')
const streamSignalReason = ref<string | null>(null)
const streamNextRetryMs = ref<number | null>(null)
// Video aspect ratio (dynamically updated from actual video dimensions)
// Using string format "width/height" to let browser handle the ratio calculation
const videoAspectRatio = ref<string | null>(null)
// Backend-provided FPS (received from WebSocket stream.stats_update events)
const backendFps = ref(0)
// Per-client statistics from backend
interface ClientStat {
id: string
fps: number // Integer: frames sent in last second
connected_secs: number
}
const clientsStats = ref<Record<string, ClientStat>>({})
// Generate a unique client ID for this browser session
// This allows us to identify our own stats in the clients_stat map
const myClientId = generateUUID()
// HID state
const mouseMode = ref<'absolute' | 'relative'>('absolute')
const pressedKeys = ref<CanonicalKey[]>([])
const keyboardLed = computed(() => ({
capsLock: systemStore.hid?.ledState.capsLock ?? false,
numLock: systemStore.hid?.ledState.numLock ?? false,
scrollLock: systemStore.hid?.ledState.scrollLock ?? false,
}))
const keyboardLedEnabled = computed(() => systemStore.hid?.keyboardLedsEnabled ?? false)
const activeModifierMask = ref(0)
const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
const isPointerLocked = ref(false) // Track pointer lock state
// Mouse move throttling (60 Hz = ~16.67ms interval)
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
let mouseMoveSendIntervalMs = DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
let mouseFlushTimer: ReturnType<typeof setTimeout> | null = null
let lastMouseMoveSendTime = 0
let pendingMouseMove: { type: 'move' | 'move_abs'; x: number; y: number } | null = null
let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas between sends
// Cursor visibility (from localStorage, updated via storage event)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
let interactionListenersBound = false
const isConsoleActive = ref(false)
function syncMouseModeFromConfig() {
const mouseAbsolute = configStore.hid?.mouse_absolute
if (typeof mouseAbsolute !== 'boolean') return
const nextMode: 'absolute' | 'relative' = mouseAbsolute ? 'absolute' : 'relative'
if (mouseMode.value !== nextMode) {
mouseMode.value = nextMode
}
}
// Virtual keyboard state
const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true)
const statsSheetOpen = ref(false)
const virtualKeyboardConsumerEnabled = computed(() => {
const hid = configStore.hid
if (!hid) return true
if (hid.backend !== HidBackend.Otg) return true
return hid.otg_functions?.consumer !== false
})
// Change password dialog state
const changePasswordDialogOpen = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const changingPassword = ref(false)
// ttyd (web terminal) state
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
const showTerminalDialog = ref(false)
// Theme
const isDark = ref(document.documentElement.classList.contains('dark'))
// Status computed (Device status removed - now only Video, Audio, HID, MSD)
const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
// If WebSocket has network error, video is also affected (same network dependency)
if (wsNetworkError.value) return 'connecting'
if (videoError.value) return 'error'
if (videoLoading.value) return 'connecting'
if (videoMode.value !== 'mjpeg') {
if (webrtc.isConnecting.value) return 'connecting'
if (webrtc.isConnected.value) return 'connected'
}
// MJPEG: check if frames have actually arrived (frontend-side detection)
// This is more reliable than relying on stream.online from backend,
// which can be stale due to the debounce delay in device_info broadcaster.
// Also handles browsers that don't fire img.onload for multipart MJPEG streams.
if (videoMode.value === 'mjpeg' && mjpegFrameReceived.value) return 'connected'
if (systemStore.stream?.online) return 'connected'
return 'disconnected'
})
// Convert resolution to short format (e.g., 720p, 1080p, 2K, 4K)
function getResolutionShortName(width: number, height: number): string {
// Common resolution mappings based on height
if (height === 2160 || (height === 2160 && width === 4096)) return '4K'
if (height === 1440) return '2K'
if (height === 1080) return '1080p'
if (height === 720) return '720p'
if (height === 768) return '768p'
if (height === 600) return '600p'
if (height === 1024 && width === 1280) return '1024p'
if (height === 960) return '960p'
// Fallback: use height + 'p'
return `${height}p`
}
// Quick info for status card trigger
const videoQuickInfo = computed(() => {
const stream = systemStore.stream
if (!stream?.resolution) return ''
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
return `${resShort} ${formatFpsValue(backendFps.value)}fps`
})
const videoDetails = computed<StatusDetail[]>(() => {
const stream = systemStore.stream
if (!stream) return []
const receivedFps = backendFps.value
// Input (capture) format → output (delivery) mode
const inputFmt = stream.format || 'MJPEG'
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt}${outputFmt}`
// Target / actual FPS combined
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
return [
{ label: t('statusCard.device'), value: stream.device || '-' },
{ label: t('statusCard.format'), value: formatDisplay },
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
{ label: t('statusCard.fps'), value: fpsDisplay, status: fpsStatus },
]
})
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const hid = systemStore.hid
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
if (hid?.error) return 'error'
// In WebRTC mode, check DataChannel status first
if (videoMode.value !== 'mjpeg') {
// DataChannel is ready - HID is connected via WebRTC
if (webrtc.dataChannelReady.value) return 'connected'
// WebRTC is connecting - HID is also connecting
if (webrtc.isConnecting.value) return 'connecting'
// WebRTC is connected but DataChannel not ready - still connecting
if (webrtc.isConnected.value) return 'connecting'
// WebRTC not connected - fall through to WebSocket check as fallback
}
// MJPEG mode or WebRTC fallback: check WebSocket HID status
// If HID WebSocket has network error, show connecting (yellow)
if (hidWs.networkError.value) return 'connecting'
// If HID WebSocket is not connected (disconnected without error), show disconnected
if (!hidWs.connected.value) return 'disconnected'
// If HID backend is unavailable (business error), show disconnected (gray)
if (hidWs.hidUnavailable.value) return 'disconnected'
// Normal status based on system state
if (hid?.available && hid.online) return 'connected'
if (hid?.available && hid.initialized) return 'connecting'
return 'disconnected'
})
// Quick info for HID status card trigger
const hidQuickInfo = computed(() => {
const hid = systemStore.hid
if (!hid?.available) return ''
// Show current mode, not hardware capability
return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative')
})
function extractCh9329Command(reason?: string | null): string | null {
if (!reason) return null
const match = reason.match(/cmd 0x([0-9a-f]{2})/i)
const cmd = match?.[1]
return cmd ? `0x${cmd.toUpperCase()}` : null
}
function hidErrorHint(errorCode?: string | null, backend?: string | null, reason?: string | null): string {
const ch9329Command = extractCh9329Command(reason)
switch (errorCode) {
case 'udc_not_configured':
return t('hid.errorHints.udcNotConfigured')
case 'disabled':
return t('hid.errorHints.disabled')
case 'enoent':
return t('hid.errorHints.hidDeviceMissing')
case 'not_opened':
return t('hid.errorHints.notOpened')
case 'port_not_found':
return t('hid.errorHints.portNotFound')
case 'invalid_config':
return t('hid.errorHints.invalidConfig')
case 'no_response':
return t(ch9329Command ? 'hid.errorHints.noResponseWithCmd' : 'hid.errorHints.noResponse', {
cmd: ch9329Command ?? '',
})
case 'protocol_error':
case 'invalid_response':
return t('hid.errorHints.protocolError')
case 'enxio':
case 'enodev':
return t('hid.errorHints.deviceDisconnected')
case 'eio':
case 'epipe':
case 'eshutdown':
case 'io_error':
case 'write_failed':
case 'read_failed':
if (backend === 'otg') return t('hid.errorHints.otgIoError')
if (backend === 'ch9329') return t('hid.errorHints.ch9329IoError')
return t('hid.errorHints.ioError')
case 'serial_error':
return t('hid.errorHints.serialError')
case 'init_failed':
return t('hid.errorHints.initFailed')
case 'shutdown':
return t('hid.errorHints.shutdown')
default:
return ''
}
}
function buildHidErrorMessage(reason?: string | null, errorCode?: string | null, backend?: string | null): string {
if (!reason && !errorCode) return ''
const hint = hidErrorHint(errorCode, backend, reason)
if (hint) return hint
if (reason) return reason
return hint || t('common.error')
}
const hidErrorMessage = computed(() => {
const hid = systemStore.hid
return buildHidErrorMessage(hid?.error, hid?.errorCode, hid?.backend)
})
const hidDetails = computed<StatusDetail[]>(() => {
const hid = systemStore.hid
if (!hid) return []
const errorMessage = buildHidErrorMessage(hid.error, hid.errorCode, hid.backend)
const hidErrorStatus: StatusDetail['status'] =
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
const details: StatusDetail[] = []
// Backend + device combined
const backendStr = hid.backend || t('common.unknown')
const deviceStr = hid.device ? ` @ ${hid.device}` : ''
details.push({ label: t('statusCard.backend'), value: `${backendStr}${deviceStr}` })
// Error message (with error code as suffix when present) OR normal-state info
if (errorMessage) {
const codeSuffix = hid.errorCode ? ` (${hid.errorCode})` : ''
details.push({ label: t('common.error'), value: `${errorMessage}${codeSuffix}`, status: hidErrorStatus })
} else if (hid.online) {
details.push({ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' })
if (hid.keyboardLedsEnabled) {
details.push({
label: t('settings.otgKeyboardLeds'),
value: `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`,
status: 'ok',
})
}
}
// Channel (merged with availability / connection state)
let channelValue: string
let channelStatus: StatusDetail['status']
if (videoMode.value !== 'mjpeg') {
if (webrtc.dataChannelReady.value) {
channelValue = 'DataChannel (WebRTC)'
channelStatus = 'ok'
} else if (webrtc.isConnecting.value || webrtc.isConnected.value) {
channelValue = 'DataChannel'
channelStatus = 'warning'
} else {
channelValue = 'WebSocket (fallback)'
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
}
} else {
channelValue = 'WebSocket'
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
}
if (videoMode.value === 'mjpeg' || !webrtc.dataChannelReady.value) {
if (hidWs.networkError.value) {
channelValue += ` (${t('statusCard.networkError')})`
channelStatus = 'warning'
} else if (!hidWs.connected.value) {
channelValue += ` (${t('statusCard.disconnected')})`
channelStatus = 'warning'
} else if (hidWs.hidUnavailable.value) {
channelValue += ` (${t('statusCard.hidUnavailable')})`
channelStatus = 'warning'
}
}
details.push({ label: t('statusCard.channel'), value: channelValue, status: channelStatus })
return details
})
// Audio status computed
const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const audio = systemStore.audio
if (!audio?.available) return 'disconnected'
if (audio.error) return 'error'
if (audio.streaming) return 'connected'
return 'disconnected'
})
// Helper function to translate audio quality
function translateAudioQuality(quality: string | undefined): string {
if (!quality) return t('common.unknown')
const qualityLower = quality.toLowerCase()
if (qualityLower === 'voice') return t('actionbar.qualityVoice')
if (qualityLower === 'balanced') return t('actionbar.qualityBalanced')
if (qualityLower === 'high') return t('actionbar.qualityHigh')
return quality // fallback to original value
}
const audioQuickInfo = computed(() => {
const audio = systemStore.audio
if (!audio?.available) return ''
if (audio.streaming) return translateAudioQuality(audio.quality)
return t('statusCard.off')
})
const audioErrorMessage = computed(() => {
return systemStore.audio?.error || ''
})
const audioDetails = computed<StatusDetail[]>(() => {
const audio = systemStore.audio
if (!audio) return []
return [
{ label: t('statusCard.device'), value: audio.device || t('statusCard.defaultDevice') },
{ label: t('statusCard.quality'), value: translateAudioQuality(audio.quality) },
{ label: t('statusCard.streaming'), value: audio.streaming ? t('statusCard.yes') : t('statusCard.no'), status: audio.streaming ? 'ok' : undefined },
]
})
// MSD status computed
const msdStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const msd = systemStore.msd
if (!msd?.available) return 'disconnected'
if (msd.error) return 'error'
if (msd.connected) return 'connected'
return 'disconnected'
})
const msdQuickInfo = computed(() => {
const msd = systemStore.msd
if (!msd?.available) return ''
if (msd.mode === 'none') return t('statusCard.msdStandby')
if (msd.mode === 'image') return t('statusCard.msdImageMode')
if (msd.mode === 'drive') return t('statusCard.msdDriveMode')
return t('common.unknown')
})
const msdErrorMessage = computed(() => {
return systemStore.msd?.error || ''
})
const msdDetails = computed<StatusDetail[]>(() => {
const msd = systemStore.msd
if (!msd) return []
const details: StatusDetail[] = []
// 状态:待机 / 已连接
if (msd.mode === 'none') {
details.push({
label: t('statusCard.msdStatus'),
value: t('statusCard.msdStandby'),
status: undefined
})
} else {
details.push({
label: t('statusCard.msdStatus'),
value: t('statusCard.connected'),
status: 'ok'
})
}
// 模式
const modeDisplay = msd.mode === 'none'
? '-'
: msd.mode === 'image'
? t('statusCard.msdImageMode')
: t('statusCard.msdDriveMode')
details.push({
label: t('statusCard.mode'),
value: modeDisplay,
status: msd.mode !== 'none' ? 'ok' : undefined
})
// 当前镜像(仅在 image 模式下显示)
if (msd.mode === 'image') {
details.push({
label: t('statusCard.msdCurrentImage'),
value: msd.imageId || t('statusCard.msdNoImage')
})
}
return details
})
const webrtcLoadingMessage = computed(() => {
if (videoMode.value === 'mjpeg') {
return videoRestarting.value ? t('console.videoRestarting') : t('console.connecting')
}
switch (webrtc.connectStage.value) {
case 'fetching_ice_servers':
return t('console.webrtcPhaseIceServers')
case 'creating_peer_connection':
return t('console.webrtcPhaseCreatePeer')
case 'creating_data_channel':
return t('console.webrtcPhaseCreateChannel')
case 'creating_offer':
return t('console.webrtcPhaseCreateOffer')
case 'waiting_server_answer':
return t('console.webrtcPhaseWaitAnswer')
case 'setting_remote_description':
return t('console.webrtcPhaseSetRemote')
case 'applying_ice_candidates':
return t('console.webrtcPhaseApplyIce')
case 'waiting_connection':
return t('console.webrtcPhaseNegotiating')
case 'connected':
return t('console.webrtcConnected')
case 'failed':
return t('console.webrtcFailed')
default:
return videoRestarting.value ? t('console.videoRestarting') : t('console.connecting')
}
})
const showMsdStatusCard = computed(() => {
return !!(systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329')
})
const hidHoverAlign = computed<'start' | 'end'>(() => {
return showMsdStatusCard.value ? 'start' : 'end'
})
// Video handling
let retryTimeoutId: number | null = null
let retryCount = 0
let gracePeriodTimeoutId: number | null = null
let consecutiveErrors = 0
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
let pendingWebRTCReadyGate = false
let webrtcConnectTask: Promise<boolean> | null = null
// WebRTC auto-reconnect on device-lost/recovery
let webrtcRecoveryTimerId: number | null = null
let webrtcRecoveryAttempts = 0
const MAX_WEBRTC_RECOVERY_ATTEMPTS = 8
const WEBRTC_RECOVERY_BASE_DELAY = 2000
// 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)
})
}
/** For WebRTC watch: skip auto-reconnect when these hold. */
function shouldSuppressAutoReconnect(): boolean {
return videoMode.value === 'mjpeg'
|| !isConsoleActive.value
|| videoSession.localSwitching.value
|| videoSession.backendSwitching.value
|| videoRestarting.value
}
function markWebRTCFailure(reason: string, description?: string) {
pendingWebRTCReadyGate = false
videoError.value = true
videoErrorMessage.value = reason
videoLoading.value = false
systemStore.setStreamOnline(false)
toast.error(reason, {
description: description ?? '',
duration: 5000,
})
}
async function waitForWebRTCReadyGate(reason: string, timeoutMs = 3000): Promise<void> {
if (!pendingWebRTCReadyGate) return
const ready = await videoSession.waitForWebRTCReadyAny(timeoutMs)
if (!ready) {
console.warn(`[WebRTC] Ready gate timeout (${reason}), attempting connection anyway`)
}
pendingWebRTCReadyGate = false
}
async function connectWebRTCSerial(reason: string): Promise<boolean> {
if (webrtcConnectTask) {
return webrtcConnectTask
}
webrtcConnectTask = (async () => {
await waitForWebRTCReadyGate(reason)
return webrtc.connect()
})()
try {
return await webrtcConnectTask
} finally {
webrtcConnectTask = null
}
}
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
if (videoMode.value === 'mjpeg') {
mjpegFrameReceived.value = true
systemStore.setStreamOnline(true)
// Update aspect ratio from MJPEG image dimensions
const img = videoRef.value
if (img && img.naturalWidth && img.naturalHeight) {
videoAspectRatio.value = `${img.naturalWidth}/${img.naturalHeight}`
}
}
if (!videoLoading.value) {
// 非首帧只做计数
return
}
// Clear any pending retries and grace period
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
if (gracePeriodTimeoutId !== null) {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
}
// Reset all error states
videoLoading.value = false
videoError.value = false
videoErrorMessage.value = ''
videoRestarting.value = false
retryCount = 0
consecutiveErrors = 0
clearFrameOverlay()
// Auto-focus video container for immediate keyboard input
const container = videoContainerRef.value
if (container && typeof container.focus === 'function') {
container.focus()
}
}
function handleVideoError() {
// 如果当前是 WebRTC 模式,忽略 MJPEG 错误(因为我们主动清空了 src
if (videoMode.value !== 'mjpeg') {
return
}
// 如果正在切换模式,忽略错误(可能是 503 错误,因为后端已切换模式)
if (isModeSwitching.value) {
return
}
// 如果正在刷新视频,忽略清空 src 时触发的错误
if (isRefreshingVideo) {
return
}
// Expected <img> error while overlay shows no_signal / device_* — do not retry.
if (streamSignalState.value !== 'ok') {
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
videoLoading.value = false
mjpegFrameReceived.value = false
return
}
// Count consecutive errors even in grace period
consecutiveErrors++
// If too many errors even in grace period, it's a real failure
if (consecutiveErrors > MAX_CONSECUTIVE_ERRORS && gracePeriodTimeoutId !== null) {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
videoRestarting.value = false
}
// If in grace period and not too many errors, ignore
if (videoRestarting.value || gracePeriodTimeoutId !== null) {
return
}
// Clear any pending retries to avoid duplicate attempts
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
// Show loading state immediately
videoLoading.value = true
mjpegFrameReceived.value = false
// Auto-retry with exponential backoff (infinite retry, capped delay)
retryCount++
const delay = BASE_RETRY_DELAY * Math.pow(1.5, Math.min(retryCount - 1, 5))
retryTimeoutId = window.setTimeout(() => {
retryTimeoutId = null
refreshVideo()
}, delay)
}
// Stream device monitoring handlers (UI-only; notifications/state are handled by useConsoleEvents)
function handleStreamDeviceLost(data: { device: string; reason: string }) {
videoError.value = true
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
// In WebRTC mode, the pipeline will attempt to restart itself.
// Start an exponential-backoff reconnect loop so the session is
// re-established automatically once the backend is ready again.
if (videoMode.value !== 'mjpeg') {
scheduleWebRTCRecovery()
}
}
function scheduleWebRTCRecovery() {
// Clear any previous timer
if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null
}
if (webrtcRecoveryAttempts >= MAX_WEBRTC_RECOVERY_ATTEMPTS) {
console.warn('[Recovery] Max WebRTC recovery attempts reached, giving up')
webrtcRecoveryAttempts = 0
return
}
const delay = Math.min(
WEBRTC_RECOVERY_BASE_DELAY * Math.pow(2, webrtcRecoveryAttempts),
30000,
)
console.log(
`[Recovery] Scheduling WebRTC reconnect attempt ${webrtcRecoveryAttempts + 1}/${MAX_WEBRTC_RECOVERY_ATTEMPTS} in ${delay}ms`,
)
webrtcRecoveryTimerId = window.setTimeout(async () => {
webrtcRecoveryTimerId = null
webrtcRecoveryAttempts++
// Only reconnect if we are still in a WebRTC mode and error state
if (videoMode.value === 'mjpeg' || !videoError.value) {
webrtcRecoveryAttempts = 0
return
}
console.log(`[Recovery] Attempting WebRTC reconnect (attempt ${webrtcRecoveryAttempts})`)
try {
await webrtc.disconnect()
const ok = await connectWebRTCSerial('device-recovery')
if (ok) {
console.log('[Recovery] WebRTC reconnected successfully')
videoError.value = false
videoErrorMessage.value = ''
webrtcRecoveryAttempts = 0
} else {
// Retry
scheduleWebRTCRecovery()
}
} catch {
scheduleWebRTCRecovery()
}
}, delay)
}
function cancelWebRTCRecovery() {
if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null
}
webrtcRecoveryAttempts = 0
}
function handleStreamRecovered(_data: { device: string }) {
// Cancel any pending recovery timer backend is back
cancelWebRTCRecovery()
// Reset video error state
videoError.value = false
videoErrorMessage.value = ''
// Refresh video stream
refreshVideo()
}
async function handleAudioStateChanged(data: { streaming: boolean; device: string | null }) {
if (!data.streaming) {
// Audio stopped, disconnect
unifiedAudio.disconnect()
return
}
// Audio started streaming
if (videoMode.value !== 'mjpeg' && webrtc.isConnected.value) {
// WebRTC mode: check if we have an audio track
if (!webrtc.audioTrack.value) {
// No audio track - need to reconnect WebRTC to get one
// This happens when audio was enabled after WebRTC session was created
await webrtc.disconnect()
await new Promise(resolve => setTimeout(resolve, 300))
await connectWebRTCSerial('audio track refresh')
// After reconnect, the new session will have audio track
// and the watch on audioTrack will add it to MediaStream
} else {
// We have audio track, ensure it's in MediaStream
const currentStream = webrtcVideoRef.value?.srcObject as MediaStream | null
if (currentStream && currentStream.getAudioTracks().length === 0) {
currentStream.addTrack(webrtc.audioTrack.value)
}
}
}
// Connect unified audio when streaming starts (works for both MJPEG and WebRTC modes)
// In MJPEG mode, this connects the WebSocket audio player
// In WebRTC mode, this unmutes the video element
await unifiedAudio.connect()
}
function handleStreamConfigChanging(data: any) {
// Clear any existing retries and grace periods
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
if (gracePeriodTimeoutId !== null) {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
}
// Reset all counters and states
videoRestarting.value = true
pendingWebRTCReadyGate = true
videoLoading.value = true
videoError.value = false
retryCount = 0
consecutiveErrors = 0
// Reset FPS when config changes (backend will send new FPS via WebSocket)
backendFps.value = 0
toast.info(t('console.videoRestarting'), {
description: data.reason === 'device_switch' ? t('console.deviceSwitching') : t('console.configChanging'),
duration: 5000,
})
}
async function handleStreamConfigApplied(data: any) {
// Reset consecutive error counter for new config
consecutiveErrors = 0
// Start grace period to ignore transient errors
gracePeriodTimeoutId = window.setTimeout(() => {
gracePeriodTimeoutId = null
consecutiveErrors = 0 // Also reset when grace period ends
}, GRACE_PERIOD)
// Refresh video based on current mode
videoRestarting.value = true
// 如果正在进行模式切换不需要在这里处理WebRTCReady 事件会处理)
if (isModeSwitching.value) {
console.log('[StreamConfigApplied] Mode switch in progress, waiting for WebRTCReady')
return
}
if (videoMode.value !== 'mjpeg') {
// In WebRTC mode, reconnect WebRTC (session was closed due to config change)
// connectWebRTCSerial() will wait on stream.webrtc_ready when gate is enabled.
await switchToWebRTC(videoMode.value)
} else {
// In MJPEG mode, refresh the MJPEG stream
refreshVideo()
}
videoRestarting.value = false
toast.success(t('console.videoRestarted'), {
description: `${data.device} - ${data.resolution[0]}x${data.resolution[1]} @ ${formatFpsValue(data.fps)}fps`,
duration: 3000,
})
}
// 处理 WebRTC 就绪事件 - 这是后端真正准备好接受 WebRTC 连接的信号
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 || '-'}`)
pendingWebRTCReadyGate = false
videoSession.onWebRTCReady(data)
}
function handleStreamModeReady(data: { transition_id: string; mode: string }) {
videoSession.onModeReady(data)
if (data.mode === 'mjpeg') {
pendingWebRTCReadyGate = false
}
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(() => {})
}
pendingWebRTCReadyGate = true
videoSession.onModeSwitching(data)
}
function handleStreamStateChanged(data: any) {
const state = typeof data?.state === 'string' ? data.state : ''
const reason = typeof data?.reason === 'string' && data.reason.length > 0 ? data.reason : null
const nextRetry = typeof data?.next_retry_ms === 'number' && data.next_retry_ms > 0
? data.next_retry_ms
: null
streamSignalReason.value = reason
streamNextRetryMs.value = nextRetry
const previous = streamSignalState.value
switch (state) {
case 'streaming':
case 'ready':
case 'uninitialized':
streamSignalState.value = 'ok'
break
case 'no_signal':
streamSignalState.value = 'no_signal'
break
case 'device_lost':
streamSignalState.value = 'device_lost'
break
case 'device_busy':
streamSignalState.value = 'device_busy'
break
}
if (state === 'error') {
videoError.value = true
videoErrorMessage.value = t('console.streamError')
} else if (state === 'no_signal' && videoMode.value !== 'mjpeg') {
cancelWebRTCRecovery()
videoRestarting.value = false
videoError.value = false
videoErrorMessage.value = ''
} else if (state === 'device_busy' && videoMode.value !== 'mjpeg') {
cancelWebRTCRecovery()
videoRestarting.value = true
videoLoading.value = true
videoError.value = false
videoErrorMessage.value = ''
if (previous !== 'device_busy') {
captureFrameOverlay().catch(() => {})
}
} else if (state === 'device_lost' && videoMode.value !== 'mjpeg') {
if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
scheduleWebRTCRecovery()
}
} else if (state === 'streaming') {
cancelWebRTCRecovery()
videoError.value = false
videoErrorMessage.value = ''
videoRestarting.value = false
if (
videoMode.value === 'mjpeg'
&& (previous === 'no_signal' || previous === 'device_lost' || previous === 'device_busy')
) {
refreshVideo()
} else if (
videoMode.value !== 'mjpeg'
&& (previous === 'no_signal' || previous === 'device_busy' || previous === 'device_lost')
) {
if (webrtc.isConnected.value && !webrtc.isConnecting.value) {
void rebindWebRTCVideo().then(() => {
videoLoading.value = false
})
} else if (!webrtc.isConnected.value && !webrtc.isConnecting.value) {
void connectWebRTCSerial('stream recovered').then(async (ok) => {
if (ok) {
await rebindWebRTCVideo()
videoLoading.value = false
} else if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
scheduleWebRTCRecovery()
}
})
}
}
}
}
const showSignalOverlay = computed(() => streamSignalState.value !== 'ok')
const signalOverlayInfo = computed(() => {
const reason = streamSignalReason.value
const reasonHintKey = reason ? `console.signal.reason.${reason}` : ''
const hint = reasonHintKey && te(reasonHintKey) ? t(reasonHintKey) : ''
switch (streamSignalState.value) {
case 'no_signal':
return {
title: t('console.signal.noSignal.title'),
detail: t('console.signal.noSignal.detail'),
hint,
tone: 'info' as const,
}
case 'device_lost':
return {
title: t('console.signal.deviceLost.title'),
detail: t('console.signal.deviceLost.detail'),
hint,
tone: 'error' as const,
}
case 'device_busy':
return {
title: t('console.signal.deviceBusy.title'),
detail: t('console.signal.deviceBusy.detail'),
hint,
tone: 'info' as const,
}
default:
return { title: '', detail: '', hint: '', tone: 'info' as const }
}
})
function handleStreamStatsUpdate(data: any) {
// Always update clients count in store (for MJPEG mode display)
if (typeof data.clients === 'number') {
systemStore.updateStreamClients(data.clients)
}
// Only update FPS from MJPEG stats when in MJPEG mode
// In WebRTC mode, FPS is updated from WebRTC stats
if (videoMode.value !== 'mjpeg') {
// Still update clientsStats for display purposes, but don't touch backendFps
if (data.clients_stat && typeof data.clients_stat === 'object') {
clientsStats.value = data.clients_stat
}
return
}
if (data.clients_stat && typeof data.clients_stat === 'object') {
clientsStats.value = data.clients_stat
const myStats = data.clients_stat[myClientId]
if (myStats) {
backendFps.value = myStats.fps || 0
} else {
const fpsList = Object.values(data.clients_stat)
.map((s: any) => s?.fps || 0)
.filter(f => f > 0)
backendFps.value = fpsList.length > 0 ? Math.min(...fpsList) : 0
}
} else {
backendFps.value = 0
}
}
// Track if we've received the initial device_info
let initialDeviceInfoReceived = false
let initialModeRestoreDone = false
let initialModeRestoreInProgress = false
function normalizeServerMode(mode: string | undefined): VideoMode | null {
if (!mode) return null
if (mode === 'webrtc') return 'h264'
if (mode === 'mjpeg' || mode === 'h264' || mode === 'h265' || mode === 'vp8' || mode === 'vp9') {
return mode
}
return null
}
async function restoreInitialMode(serverMode: VideoMode) {
if (initialModeRestoreDone || initialModeRestoreInProgress) return
initialModeRestoreInProgress = true
try {
initialDeviceInfoReceived = true
if (serverMode !== videoMode.value) {
videoMode.value = serverMode
localStorage.setItem('videoMode', serverMode)
}
if (serverMode !== 'mjpeg') {
await connectWebRTCOnly(serverMode)
} else if (mjpegTimestamp.value === 0) {
refreshVideo()
}
initialModeRestoreDone = true
} finally {
initialModeRestoreInProgress = false
}
}
function handleDeviceInfo(data: any) {
const prevAudioStreaming = systemStore.audio?.streaming ?? false
const prevAudioDevice = systemStore.audio?.device ?? null
systemStore.updateFromDeviceInfo(data)
ttydStatus.value = data.ttyd ?? null
const nextAudioStreaming = systemStore.audio?.streaming ?? false
const nextAudioDevice = systemStore.audio?.device ?? null
if (
prevAudioStreaming !== nextAudioStreaming ||
prevAudioDevice !== nextAudioDevice
) {
void handleAudioStateChanged({
streaming: nextAudioStreaming,
device: nextAudioDevice,
})
}
// Skip mode sync if video config is being changed
// This prevents false-positive mode changes during config switching
if (data.video?.config_changing) {
return
}
// Sync video mode from server's stream_mode
if (data.video?.stream_mode) {
const serverMode = normalizeServerMode(data.video.stream_mode)
if (!serverMode) return
if (!initialDeviceInfoReceived) {
initialDeviceInfoReceived = true
if (!initialModeRestoreDone && !initialModeRestoreInProgress) {
void restoreInitialMode(serverMode)
return
}
}
if (initialModeRestoreInProgress) return
if (serverMode !== videoMode.value) {
syncToServerMode(serverMode)
}
}
}
// Handle stream mode change event from server (WebSocket broadcast)
function handleStreamModeChanged(data: { mode: string; previous_mode: string }) {
const newMode = normalizeServerMode(data.mode)
if (!newMode) return
// 如果正在进行模式切换,忽略这个事件(这是我们自己触发的切换产生的)
if (isModeSwitching.value) {
console.log('[StreamModeChanged] Mode switch in progress, ignoring event')
return
}
// Show toast notification only if this is an external mode change
toast.info(t('console.streamModeChanged'), {
description: t('console.streamModeChangedDesc', { mode: data.mode.toUpperCase() }),
duration: 5000,
})
// Switch to new mode (external sync handled by device_info after mode_ready)
if (newMode !== videoMode.value) {
syncToServerMode(newMode)
}
}
// 标记是否正在刷新视频(用于忽略清空 src 时触发的 error 事件)
let isRefreshingVideo = false
// 标记是否正在切换模式(防止竞态条件和 503 错误)
const isModeSwitching = videoSession.localSwitching
function reloadPage() {
window.location.reload()
}
function refreshVideo() {
backendFps.value = 0
videoError.value = false
videoErrorMessage.value = ''
mjpegFrameReceived.value = false
// Update timestamp to force MJPEG reconnection via reactive URL
isRefreshingVideo = true
videoLoading.value = true
mjpegTimestamp.value = Date.now()
// For MJPEG streams, the 'load' event fires when first frame arrives
// But on reconnection it may not fire again, so use a timeout as fallback
setTimeout(() => {
isRefreshingVideo = false
// Clear loading state after timeout - if stream failed, error handler will show error
if (videoLoading.value) {
videoLoading.value = false
}
}, 1500)
}
// MJPEG URL with cache-busting timestamp (reactive)
// Only return valid URL when in MJPEG mode and the backend reports a
// healthy stream. When the backend goes offline (no_signal / device_lost
// / device_busy) we deliberately return an empty string so the `<img>`
// tag has no `src` and the 4-state overlay fully owns the video area —
// no more fake placeholder JPEG peeking through.
const mjpegTimestamp = ref(0)
const mjpegUrl = computed(() => {
if (videoMode.value !== 'mjpeg') {
return '' // Don't load MJPEG when in H264 mode
}
if (mjpegTimestamp.value === 0) {
return '' // Don't load until refreshVideo() is called
}
if (streamSignalState.value !== 'ok') {
return '' // Backend is offline; let the overlay own the viewport
}
return `${streamApi.getMjpegUrl(myClientId)}&t=${mjpegTimestamp.value}`
})
// Connect to WebRTC without changing server mode (for new clients joining existing session)
async function connectWebRTCOnly(codec: VideoMode = 'h264') {
// 清除 MJPEG 相关的定时器
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
if (gracePeriodTimeoutId !== null) {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
}
retryCount = 0
consecutiveErrors = 0
// 停止 MJPEG 流 - 重置 timestamp 以停止请求
mjpegTimestamp.value = 0
if (videoRef.value) {
videoRef.value.src = ''
videoRef.value.removeAttribute('src')
}
videoLoading.value = true
videoError.value = false
videoErrorMessage.value = ''
try {
const success = await connectWebRTCSerial('connectWebRTCOnly')
if (success) {
toast.success(t('console.webrtcConnected'), {
description: t('console.webrtcConnectedDesc'),
duration: 3000,
})
// 强制重新绑定视频(即使 track 已存在)
// 这解决了页面返回时视频不显示的问题
await rebindWebRTCVideo()
videoLoading.value = false
videoMode.value = codec
unifiedAudio.switchMode('webrtc')
} else {
throw new Error('WebRTC connection failed')
}
} catch {
markWebRTCFailure(t('console.webrtcFailed'))
}
}
// 强制重新绑定 WebRTC 视频到视频元素
// 解决页面切换后视频不显示的问题
async function rebindWebRTCVideo() {
if (!webrtcVideoRef.value) return
// 先清空再重新绑定,确保浏览器重新渲染
webrtcVideoRef.value.srcObject = null
await nextTick()
if (webrtc.videoTrack.value) {
const stream = webrtc.getMediaStream()
if (stream) {
webrtcVideoRef.value.srcObject = stream
try {
await webrtcVideoRef.value.play()
} catch {
// AbortError is expected when switching modes quickly, ignore it
}
await waitForVideoFirstFrame(webrtcVideoRef.value, 2000)
clearFrameOverlay()
}
}
}
// WebRTC video mode handling (switches server mode)
async function switchToWebRTC(codec: VideoMode = 'h264') {
// 清除 MJPEG 相关的定时器,防止切换后重新加载 MJPEG
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
if (gracePeriodTimeoutId !== null) {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
}
retryCount = 0
consecutiveErrors = 0
// 停止 MJPEG 流 - 重置 timestamp 以停止请求
mjpegTimestamp.value = 0
if (videoRef.value) {
videoRef.value.src = ''
}
videoLoading.value = true
videoError.value = false
videoErrorMessage.value = ''
pendingWebRTCReadyGate = true
try {
// Step 1: Disconnect existing WebRTC connection FIRST
// This prevents ICE candidates from being sent to stale sessions
// when backend closes sessions during codec switch
if (webrtc.isConnected.value || webrtc.sessionId.value) {
await webrtc.disconnect()
}
// Step 2: Call backend API to switch mode with specific 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),
])
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')
}
}
// Step 3: Connect WebRTC with retry (backoff between retries)
const MAX_ATTEMPTS = 3
const RETRY_DELAYS = [200, 800]
let success = false
for (let attempt = 0; attempt < MAX_ATTEMPTS && !success; attempt++) {
if (attempt > 0) {
const delay = RETRY_DELAYS[attempt - 1] ?? RETRY_DELAYS[RETRY_DELAYS.length - 1]
console.log(`[WebRTC] Connection failed, retrying in ${delay}ms (${MAX_ATTEMPTS - attempt} attempts left)`)
await new Promise(resolve => setTimeout(resolve, delay))
}
success = await connectWebRTCSerial('switchToWebRTC')
}
if (success) {
toast.success(t('console.webrtcConnected'), {
description: t('console.webrtcConnectedDesc'),
duration: 3000,
})
// 强制重新绑定视频
await rebindWebRTCVideo()
videoLoading.value = false
// Step 4: Switch audio to WebRTC mode
unifiedAudio.switchMode('webrtc')
} else {
throw new Error('WebRTC connection failed')
}
} catch {
markWebRTCFailure(t('console.webrtcFailed'))
}
}
async function switchToMJPEG() {
videoLoading.value = true
videoError.value = false
videoErrorMessage.value = ''
pendingWebRTCReadyGate = false
// Step 1: Call backend API to switch mode FIRST
// This ensures the MJPEG endpoint will accept our request
try {
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
}
// Step 2: Disconnect WebRTC if connected or session still exists
if (webrtc.isConnected.value || webrtc.sessionId.value) {
await webrtc.disconnect()
}
// Clear WebRTC video
if (webrtcVideoRef.value) {
webrtcVideoRef.value.srcObject = null
}
// Step 3: Switch audio to WebSocket mode
unifiedAudio.switchMode('ws')
// Refresh MJPEG stream
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 (!videoSession.tryStartLocalSwitch()) {
console.log('[VideoMode] Switch throttled or in progress, ignoring')
return
}
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') {
mjpegTimestamp.value = 0
// 完全清理 MJPEG 图片元素
if (videoRef.value) {
videoRef.value.src = ''
videoRef.value.removeAttribute('src')
}
// 等待一小段时间确保浏览器取消 pending 请求
await new Promise(resolve => setTimeout(resolve, 50))
}
videoMode.value = mode
localStorage.setItem('videoMode', mode)
// All WebRTC modes: h264, h265, vp8, vp9
if (mode !== 'mjpeg') {
await switchToWebRTC(mode)
} else {
await switchToMJPEG()
}
} finally {
videoSession.endLocalSwitch()
}
}
// Watch for WebRTC video track changes
watch(() => webrtc.videoTrack.value, async (track) => {
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
// 使用统一的重新绑定函数
await rebindWebRTCVideo()
}
})
// Watch for WebRTC audio track changes - update MediaStream when audio arrives
watch(() => webrtc.audioTrack.value, async (track) => {
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
// Audio track arrived, update the MediaStream to include it
const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null
if (currentStream && currentStream.getAudioTracks().length === 0) {
// Add audio track to existing stream
currentStream.addTrack(track)
}
}
})
// Watch for WebRTC video element ref changes - set unified audio element
watch(webrtcVideoRef, (el) => {
unifiedAudio.setWebRTCElement(el)
}, { immediate: true })
// Watch for WebRTC stats to update FPS display
// Watch the ref directly with deep: true to detect property changes
watch(webrtc.stats, (stats) => {
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
backendFps.value = Math.round(stats.framesPerSecond)
// WebRTC is receiving frames, set stream online
systemStore.setStreamOnline(true)
// Update aspect ratio from WebRTC video dimensions
if (stats.frameWidth && stats.frameHeight) {
videoAspectRatio.value = `${stats.frameWidth}/${stats.frameHeight}`
}
}
}, { deep: true })
// Watch for WebRTC connection state changes - auto-reconnect on disconnect
let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
let webrtcReconnectFailures = 0
watch(() => webrtc.state.value, (newState, oldState) => {
console.log('[WebRTC] State changed:', oldState, '->', newState)
// Clear any pending reconnect
if (webrtcReconnectTimeout) {
clearTimeout(webrtcReconnectTimeout)
webrtcReconnectTimeout = null
}
// Run before `shouldSuppressAutoReconnect()` so `device_busy` / `videoRestarting`
// never blocks clearing the loading overlay when ICE becomes connected.
if (videoMode.value !== 'mjpeg') {
if (newState === 'connected') {
systemStore.setStreamOnline(true)
webrtcReconnectFailures = 0
if (videoLoading.value) {
void rebindWebRTCVideo().then(() => {
videoLoading.value = false
})
}
} 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
}
}
if (shouldSuppressAutoReconnect()) {
return
}
// Auto-reconnect when disconnected (but was previously connected)
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
webrtcReconnectTimeout = setTimeout(async () => {
if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') {
try {
const success = await connectWebRTCSerial('auto reconnect')
if (!success) {
webrtcReconnectFailures += 1
if (webrtcReconnectFailures >= 2) {
markWebRTCFailure(t('console.webrtcFailed'))
}
}
} catch {
webrtcReconnectFailures += 1
if (webrtcReconnectFailures >= 2) {
markWebRTCFailure(t('console.webrtcFailed'))
}
}
}
}, 1000)
}
// Handle direct 'failed' state (ICE or DTLS failure)
// Allow one automatic retry before marking as failed, consistent with
// the disconnected->reconnect path that allows 2 failures.
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
webrtcReconnectFailures += 1
if (webrtcReconnectFailures >= 2) {
markWebRTCFailure(t('console.webrtcFailed'))
} else {
webrtcReconnectTimeout = setTimeout(async () => {
if (videoMode.value !== 'mjpeg' && webrtc.state.value !== 'connected') {
const success = await connectWebRTCSerial('auto reconnect after failed')
if (!success) {
markWebRTCFailure(t('console.webrtcFailed'))
}
}
}, 1000)
}
}
})
async function toggleFullscreen() {
if (!videoContainerRef.value) return
if (!document.fullscreenElement) {
await videoContainerRef.value.requestFullscreen()
isFullscreen.value = true
} else {
await document.exitFullscreen()
isFullscreen.value = false
}
}
// Theme toggle
function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
// Logout
async function logout() {
await authStore.logout()
router.push('/login')
}
// Change password function
async function handleChangePassword() {
if (!newPassword.value || !confirmPassword.value) {
toast.error(t('auth.passwordRequired'))
return
}
if (newPassword.value !== confirmPassword.value) {
toast.error(t('auth.passwordMismatch'))
return
}
if (newPassword.value.length < 4) {
toast.error(t('auth.passwordTooShort'))
return
}
changingPassword.value = true
try {
await authApi.changePassword(currentPassword.value, newPassword.value)
toast.success(t('auth.passwordChanged'))
// Reset form and close dialog
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
changePasswordDialogOpen.value = false
} catch (e) {
// Error toast is shown by API layer
console.info('[ChangePassword] Failed:', e)
} finally {
changingPassword.value = false
}
}
// ttyd (web terminal) functions
function openTerminal() {
if (!ttydStatus.value?.running) return
showTerminalDialog.value = true
}
function openTerminalInNewTab() {
window.open('/api/terminal/', '_blank')
}
// ATX actions
async function handlePowerShort() {
try {
await atxApi.power('short')
await systemStore.fetchAtxState()
} catch {
// ATX action failed
}
}
async function handlePowerLong() {
try {
await atxApi.power('long')
await systemStore.fetchAtxState()
} catch {
// ATX action failed
}
}
async function handleReset() {
try {
await atxApi.power('reset')
await systemStore.fetchAtxState()
} catch {
// ATX action failed
}
}
async function handleWol(mac: string) {
try {
await atxConfigApi.sendWol(mac)
toast.success(t('atx.wolSent'))
} catch (e) {
toast.error(t('atx.wolFailed'))
}
}
// HID error handling - silently handle all HID errors
function handleHidError(_error: any, _operation: string) {
// All HID errors are silently ignored
}
// HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket
function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: number) {
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup',
key,
modifier,
}
const sent = webrtc.sendKeyboard(event)
if (sent) return
// Fallback to WebSocket if DataChannel send failed
}
// Use WebSocket as fallback or for MJPEG mode
hidApi.keyboard(type, key, modifier).catch(err => handleHidError(err, `keyboard ${type}`))
}
function sendMouseEvent(data: { type: 'move' | 'move_abs' | 'down' | 'up' | 'scroll'; x?: number; y?: number; button?: 'left' | 'right' | 'middle'; scroll?: number }) {
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
const event: HidMouseEvent = {
type: data.type === 'move_abs' ? 'moveabs' : data.type,
x: data.x,
y: data.y,
button: data.button === 'left' ? 0 : data.button === 'middle' ? 1 : data.button === 'right' ? 2 : undefined,
scroll: data.scroll,
}
const sent = webrtc.sendMouse(event)
if (sent) return
// Fallback to WebSocket if DataChannel send failed
}
// Use WebSocket as fallback or for MJPEG mode
hidApi.mouse(data).catch(err => handleHidError(err, `mouse ${data.type}`))
}
// Check if a key should be blocked (prevented from default behavior)
function shouldBlockKey(e: KeyboardEvent): boolean {
// In fullscreen mode, block all keys for maximum capture
if (isFullscreen.value) {
return true
}
// Don't block critical browser shortcuts in non-fullscreen mode
const key = e.key.toUpperCase()
// Don't block Ctrl+W (close tab), Ctrl+T (new tab), Ctrl+N (new window)
if (e.ctrlKey && ['W', 'T', 'N'].includes(key)) return false
// Don't block F11 (browser fullscreen toggle)
if (key === 'F11') return false
// Don't block Alt+Tab (already can't capture it anyway)
if (e.altKey && key === 'TAB') return false
// Block everything else
return true
}
// Keyboard/Mouse event handling
function handleKeyDown(e: KeyboardEvent) {
const container = videoContainerRef.value
if (!container) return
// Check focus in non-fullscreen mode
if (!isFullscreen.value && !container.contains(document.activeElement)) return
// Try to block the key if appropriate
if (shouldBlockKey(e)) {
e.preventDefault()
e.stopPropagation()
}
// Show hint for Meta key in non-fullscreen mode
if (!isFullscreen.value && (e.metaKey || e.key === 'Meta')) {
toast.info(t('console.metaKeyHint'), {
description: t('console.metaKeyHintDesc'),
duration: 3000,
})
}
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
if (canonicalKey === undefined) {
console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`)
return
}
if (!pressedKeys.value.includes(canonicalKey)) {
pressedKeys.value = [...pressedKeys.value, canonicalKey]
}
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, true)
activeModifierMask.value = modifierMask
sendKeyboardEvent('down', canonicalKey, modifierMask)
}
function handleKeyUp(e: KeyboardEvent) {
const container = videoContainerRef.value
if (!container) return
// Check focus in non-fullscreen mode
if (!isFullscreen.value && !container.contains(document.activeElement)) return
// Try to block the key if appropriate
if (shouldBlockKey(e)) {
e.preventDefault()
e.stopPropagation()
}
const canonicalKey = keyboardEventToCanonicalKey(e.code, e.key)
if (canonicalKey === undefined) {
console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`)
return
}
pressedKeys.value = pressedKeys.value.filter(k => k !== canonicalKey)
const modifierMask = updateModifierMaskForKey(activeModifierMask.value, canonicalKey, false)
activeModifierMask.value = modifierMask
sendKeyboardEvent('up', canonicalKey, modifierMask)
}
function getActiveVideoElement(): HTMLImageElement | HTMLVideoElement | null {
return videoMode.value !== 'mjpeg' ? webrtcVideoRef.value : videoRef.value
}
function getActiveVideoAspectRatio(): number | null {
if (videoMode.value !== 'mjpeg') {
const video = webrtcVideoRef.value
if (video?.videoWidth && video.videoHeight) {
return video.videoWidth / video.videoHeight
}
} else {
const image = videoRef.value
if (image?.naturalWidth && image.naturalHeight) {
return image.naturalWidth / image.naturalHeight
}
}
if (!videoAspectRatio.value) return null
const [width, height] = videoAspectRatio.value.split('/').map(Number)
if (!width || !height) return null
return width / height
}
function getRenderedVideoRect() {
const videoElement = getActiveVideoElement()
if (!videoElement) return null
const rect = videoElement.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
const contentAspectRatio = getActiveVideoAspectRatio()
if (!contentAspectRatio) {
return rect
}
const boxAspectRatio = rect.width / rect.height
if (!Number.isFinite(boxAspectRatio) || boxAspectRatio <= 0) {
return rect
}
if (boxAspectRatio > contentAspectRatio) {
const width = rect.height * contentAspectRatio
return {
left: rect.left + (rect.width - width) / 2,
top: rect.top,
width,
height: rect.height,
}
}
const height = rect.width / contentAspectRatio
return {
left: rect.left,
top: rect.top + (rect.height - height) / 2,
width: rect.width,
height,
}
}
function getAbsoluteMousePosition(e: MouseEvent) {
const rect = getRenderedVideoRect()
if (!rect) return null
const normalizedX = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
return {
x: Math.round(normalizedX * 32767),
y: Math.round(normalizedY * 32767),
}
}
function handleMouseMove(e: MouseEvent) {
const videoElement = getActiveVideoElement()
if (!videoElement) return
if (mouseMode.value === 'absolute') {
const absolutePosition = getAbsoluteMousePosition(e)
if (!absolutePosition) return
const { x, y } = absolutePosition
mousePosition.value = { x, y }
// Queue for throttled sending (absolute mode: just update pending position)
pendingMouseMove = { type: 'move_abs', x, y }
requestMouseMoveFlush()
} else {
// Relative mode: use movementX/Y when pointer is locked
if (isPointerLocked.value) {
const dx = e.movementX
const dy = e.movementY
// Only accumulate if there's actual movement
if (dx !== 0 || dy !== 0) {
// Accumulate deltas for throttled sending
accumulatedDelta.x += dx
accumulatedDelta.y += dy
requestMouseMoveFlush()
}
// Update display position (accumulated delta for display only)
mousePosition.value = {
x: mousePosition.value.x + dx,
y: mousePosition.value.y + dy,
}
}
}
}
function hasPendingMouseMove(): boolean {
if (mouseMode.value === 'absolute') return pendingMouseMove !== null
return accumulatedDelta.x !== 0 || accumulatedDelta.y !== 0
}
function flushMouseMoveOnce(): boolean {
if (mouseMode.value === 'absolute') {
if (!pendingMouseMove) return false
sendMouseEvent(pendingMouseMove)
pendingMouseMove = null
return true
}
if (accumulatedDelta.x === 0 && accumulatedDelta.y === 0) return false
// Clamp to i8 range (-127 to 127)
const clampedDx = Math.max(-127, Math.min(127, accumulatedDelta.x))
const clampedDy = Math.max(-127, Math.min(127, accumulatedDelta.y))
sendMouseEvent({ type: 'move', x: clampedDx, y: clampedDy })
// Subtract sent amount (keep remainder for next send if clamped)
accumulatedDelta.x -= clampedDx
accumulatedDelta.y -= clampedDy
return true
}
function scheduleMouseMoveFlush() {
if (mouseFlushTimer !== null) return
const interval = mouseMoveSendIntervalMs
const now = Date.now()
const elapsed = now - lastMouseMoveSendTime
const delay = interval <= 0 ? 0 : Math.max(0, interval - elapsed)
mouseFlushTimer = setTimeout(() => {
mouseFlushTimer = null
const burstLimit = mouseMoveSendIntervalMs <= 0 ? 8 : 1
let sent = false
for (let i = 0; i < burstLimit; i++) {
if (!flushMouseMoveOnce()) break
sent = true
if (!hasPendingMouseMove()) break
}
if (sent) lastMouseMoveSendTime = Date.now()
if (hasPendingMouseMove()) {
scheduleMouseMoveFlush()
}
}, delay)
}
function requestMouseMoveFlush() {
const interval = mouseMoveSendIntervalMs
const now = Date.now()
if (interval <= 0 || now - lastMouseMoveSendTime >= interval) {
const burstLimit = interval <= 0 ? 8 : 1
let sent = false
for (let i = 0; i < burstLimit; i++) {
if (!flushMouseMoveOnce()) break
sent = true
if (!hasPendingMouseMove()) break
}
if (sent) lastMouseMoveSendTime = Date.now()
if (hasPendingMouseMove()) {
scheduleMouseMoveFlush()
}
return
}
scheduleMouseMoveFlush()
}
// Track pressed mouse button for window-level mouseup handling
const pressedMouseButton = ref<'left' | 'right' | 'middle' | null>(null)
function handleMouseDown(e: MouseEvent) {
e.preventDefault()
// Auto-focus the video container to enable keyboard input
const container = videoContainerRef.value
if (container && document.activeElement !== container) {
if (typeof container.focus === 'function') {
container.focus()
}
}
// In relative mode, request pointer lock on first click
if (mouseMode.value === 'relative' && !isPointerLocked.value) {
requestPointerLock()
return
}
if (mouseMode.value === 'absolute') {
const absolutePosition = getAbsoluteMousePosition(e)
if (absolutePosition) {
mousePosition.value = absolutePosition
sendMouseEvent({ type: 'move_abs', ...absolutePosition })
pendingMouseMove = null
}
}
const button = e.button === 0 ? 'left' : e.button === 2 ? 'right' : 'middle'
pressedMouseButton.value = button
sendMouseEvent({ type: 'down', button })
}
function handleMouseUp(e: MouseEvent) {
e.preventDefault()
handleMouseUpInternal(e.button)
}
// Window-level mouseup handler (catches releases outside the container)
function handleWindowMouseUp(e: MouseEvent) {
if (pressedMouseButton.value !== null) {
handleMouseUpInternal(e.button)
}
}
function handleMouseUpInternal(rawButton: number) {
if (mouseMode.value === 'relative' && !isPointerLocked.value) {
pressedMouseButton.value = null
return
}
const button = rawButton === 0 ? 'left' : rawButton === 2 ? 'right' : 'middle'
// Only send if this button was actually pressed
if (pressedMouseButton.value !== button) {
return
}
pressedMouseButton.value = null
sendMouseEvent({ type: 'up', button })
}
function handleWheel(e: WheelEvent) {
e.preventDefault()
const scroll = e.deltaY > 0 ? -1 : 1
sendMouseEvent({ type: 'scroll', scroll })
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault()
}
// Pointer Lock API for relative mouse mode
function requestPointerLock() {
const container = videoContainerRef.value
if (!container) return
container.requestPointerLock().catch((err: Error) => {
toast.error(t('console.pointerLockFailed'), {
description: err.message,
})
})
}
function exitPointerLock() {
if (document.pointerLockElement) {
document.exitPointerLock()
}
}
function handlePointerLockChange() {
const container = videoContainerRef.value
isPointerLocked.value = document.pointerLockElement === container
if (isPointerLocked.value) {
// Reset mouse position display when locked
mousePosition.value = { x: 0, y: 0 }
toast.info(t('console.pointerLocked'), {
description: t('console.pointerLockedDesc'),
duration: 3000,
})
}
}
function handlePointerLockError() {
isPointerLocked.value = false
}
function handleFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
function handleBlur() {
pressedKeys.value = []
activeModifierMask.value = 0
// Release any pressed mouse button when window loses focus
if (pressedMouseButton.value !== null) {
const button = pressedMouseButton.value
pressedMouseButton.value = null
sendMouseEvent({ type: 'up', button })
}
}
// Handle cursor visibility change from HidConfigPopover
function handleCursorVisibilityChange(e: Event) {
const customEvent = e as CustomEvent<{ visible: boolean }>
cursorVisible.value = customEvent.detail.visible
}
function clampMouseMoveSendIntervalMs(ms: number): number {
if (!Number.isFinite(ms)) return DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
return Math.max(0, Math.min(1000, Math.floor(ms)))
}
function loadMouseMoveSendIntervalFromStorage(): number {
const raw = localStorage.getItem('hidMouseThrottle')
const parsed = raw === null ? NaN : Number(raw)
return clampMouseMoveSendIntervalMs(
Number.isFinite(parsed) ? parsed : DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
)
}
function setMouseMoveSendInterval(ms: number) {
mouseMoveSendIntervalMs = clampMouseMoveSendIntervalMs(ms)
if (mouseFlushTimer !== null) {
clearTimeout(mouseFlushTimer)
mouseFlushTimer = null
}
if (hasPendingMouseMove()) {
requestMouseMoveFlush()
}
}
function handleMouseSendIntervalChange(e: Event) {
const customEvent = e as CustomEvent<{ intervalMs: number }>
setMouseMoveSendInterval(customEvent.detail?.intervalMs)
}
function handleMouseSendIntervalStorage(e: StorageEvent) {
if (e.key !== 'hidMouseThrottle') return
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
}
function registerInteractionListeners() {
if (interactionListenersBound) return
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', handleBlur)
window.addEventListener('mouseup', handleWindowMouseUp)
window.addEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.addEventListener('storage', handleMouseSendIntervalStorage)
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
document.addEventListener('fullscreenchange', handleFullscreenChange)
interactionListenersBound = true
}
function unregisterInteractionListeners() {
if (!interactionListenersBound) return
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', handleBlur)
window.removeEventListener('mouseup', handleWindowMouseUp)
window.removeEventListener('hidCursorVisibilityChanged', handleCursorVisibilityChange as EventListener)
window.removeEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.removeEventListener('storage', handleMouseSendIntervalStorage)
document.removeEventListener('pointerlockchange', handlePointerLockChange)
document.removeEventListener('pointerlockerror', handlePointerLockError)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
interactionListenersBound = false
}
async function activateConsoleView() {
isConsoleActive.value = true
registerInteractionListeners()
// REST snapshot: returning from Settings (or other routes) may have missed WS device_info
void systemStore.fetchAllStates()
void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {})
// Ensure HID WebSocket is connected when console becomes active
if (!hidWs.connected.value) {
hidWs.connect().catch(() => {})
}
if (videoMode.value !== 'mjpeg' && webrtc.videoTrack.value) {
await nextTick()
await rebindWebRTCVideo()
return
}
if (
videoMode.value !== 'mjpeg'
&& !webrtc.isConnected.value
&& !webrtc.isConnecting.value
&& !videoSession.localSwitching.value
&& !videoSession.backendSwitching.value
&& !initialModeRestoreInProgress
) {
await connectWebRTCOnly(videoMode.value)
}
}
function deactivateConsoleView() {
isConsoleActive.value = false
handleBlur()
exitPointerLock()
unregisterInteractionListeners()
}
// ActionBar handlers
// (MSD and Settings are now handled by ActionBar component directly)
function handleToggleVirtualKeyboard() {
virtualKeyboardVisible.value = !virtualKeyboardVisible.value
}
// Virtual keyboard key event handlers
function handleVirtualKeyDown(key: CanonicalKey) {
// Add to pressedKeys for InfoBar display
if (!pressedKeys.value.includes(key)) {
pressedKeys.value = [...pressedKeys.value, key]
}
}
function handleVirtualKeyUp(key: CanonicalKey) {
// Remove from pressedKeys
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
}
function handleToggleMouseMode() {
// Exit pointer lock when switching away from relative mode
if (mouseMode.value === 'relative' && isPointerLocked.value) {
exitPointerLock()
}
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
pendingMouseMove = null
accumulatedDelta = { x: 0, y: 0 }
// Reset position when switching modes
lastMousePosition.value = { x: 0, y: 0 }
mousePosition.value = { x: 0, y: 0 }
if (mouseMode.value === 'relative') {
toast.info(t('console.relativeModeHint'), {
description: t('console.relativeModeHintDesc'),
duration: 5000,
})
}
}
// Lifecycle
onMounted(async () => {
// 1. 先订阅 WebSocket 事件,再连接(内部会 connect
consoleEvents.subscribe()
// 3. Watch WebSocket connection states and sync to store
watch([wsConnected, wsNetworkError], ([connected, netError], [_prevConnected, prevNetError]) => {
systemStore.updateWsConnection(connected, netError)
// Auto-refresh video when network recovers (wsNetworkError: true -> false)
if (prevNetError === true && netError === false && connected === true) {
refreshVideo()
}
}, { immediate: true })
watch([() => hidWs.connected.value, () => hidWs.networkError.value], ([connected, netError]) => {
systemStore.updateHidWsConnection(connected, netError)
}, { immediate: true })
// 4. 其他初始化
await systemStore.startStream().catch(() => {})
await systemStore.fetchAllStates()
await configStore.refreshHid().then(() => {
syncMouseModeFromConfig()
}).catch(() => {})
setMouseMoveSendInterval(loadMouseMoveSendIntervalFromStorage())
watch(() => configStore.hid?.mouse_absolute, () => {
syncMouseModeFromConfig()
})
const storedTheme = localStorage.getItem('theme')
if (storedTheme === 'dark' || (!storedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true
document.documentElement.classList.add('dark')
}
// Note: Video mode is now synced from server via device_info event
// The handleDeviceInfo function will automatically switch to the server's mode
// localStorage preference is only used when server mode matches
try {
const modeResp = await streamApi.getMode()
const serverMode = normalizeServerMode(modeResp?.mode)
if (serverMode && !initialModeRestoreDone && !initialModeRestoreInProgress) {
await restoreInitialMode(serverMode)
}
} catch (err) {
console.warn('[Console] Failed to fetch stream mode on enter, fallback to WS events:', err)
}
})
onActivated(() => {
void activateConsoleView()
})
onDeactivated(() => {
deactivateConsoleView()
})
onUnmounted(() => {
deactivateConsoleView()
// Reset initial device info flag
initialDeviceInfoReceived = false
initialModeRestoreDone = false
initialModeRestoreInProgress = false
// Clear mouse flush timer
if (mouseFlushTimer !== null) {
clearTimeout(mouseFlushTimer)
mouseFlushTimer = null
}
// Clear all timers
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
if (gracePeriodTimeoutId !== null) {
clearTimeout(gracePeriodTimeoutId)
gracePeriodTimeoutId = null
}
cancelWebRTCRecovery()
videoSession.clearWaiters()
// Reset counters
retryCount = 0
consoleEvents.unsubscribe()
consecutiveErrors = 0
// Disconnect WebRTC if connected or session still exists
if (webrtc.isConnected.value || webrtc.sessionId.value) {
void webrtc.disconnect()
}
// Exit pointer lock if active
exitPointerLock()
})
</script>
<template>
<div class="h-screen h-dvh flex flex-col bg-background">
<!-- Header -->
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="px-2 sm:px-4">
<div class="h-10 sm:h-14 flex items-center justify-between">
<!-- Left: Logo -->
<div class="flex items-center gap-2 sm:gap-6">
<div class="flex items-center gap-1.5 sm:gap-2">
<BrandMark size="md" class="hidden sm:block" />
<BrandMark size="sm" class="sm:hidden" />
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
</div>
<!-- Mobile Status Indicators (inline, minimal) -->
<div class="flex md:hidden items-center gap-1">
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
compact
/>
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
:hover-align="hidHoverAlign"
compact
/>
</div>
</div>
<!-- Right: Status Cards + User Menu -->
<div class="flex items-center gap-1 sm:gap-2">
<div class="hidden md:flex items-center gap-2">
<!-- Video Status -->
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
/>
<!-- Audio Status -->
<StatusCard
v-if="systemStore.audio?.available"
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
:quick-info="audioQuickInfo"
:error-message="audioErrorMessage"
:details="audioDetails"
/>
<!-- HID Status -->
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:error-message="hidErrorMessage"
:details="hidDetails"
:hover-align="hidHoverAlign"
/>
<!-- MSD Status - Hidden when CH9329 backend (no USB gadget support) -->
<StatusCard
v-if="showMsdStatusCard"
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
:quick-info="msdQuickInfo"
:error-message="msdErrorMessage"
:details="msdDetails"
hover-align="end"
/>
</div>
<!-- Separator -->
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden md:block mx-1" />
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
</Button>
<!-- Language Toggle -->
<LanguageToggleButton class="h-8 w-8 hidden md:flex" />
<!-- User Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 h-7 sm:h-9 px-2 sm:px-3">
<span class="text-xs max-w-[60px] sm:max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
<ChevronDown class="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem class="md:hidden" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4 mr-2" />
<Moon v-else class="h-4 w-4 mr-2" />
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
</DropdownMenuItem>
<DropdownMenuItem as-child class="md:hidden p-0">
<LanguageToggleButton
label-mode="next"
size="sm"
variant="ghost"
class="w-full justify-start rounded-sm px-2 py-1.5 font-normal shadow-none"
/>
</DropdownMenuItem>
<DropdownMenuSeparator class="md:hidden" />
<DropdownMenuItem @click="changePasswordDialogOpen = true">
<KeyRound class="h-4 w-4 mr-2" />
{{ t('auth.changePassword') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut class="h-4 w-4 mr-2" />
{{ t('auth.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</header>
<!-- ActionBar -->
<ActionBar
:mouse-mode="mouseMode"
:video-mode="videoMode"
:ttyd-running="ttydStatus?.running"
@toggle-fullscreen="toggleFullscreen"
@toggle-stats="statsSheetOpen = true"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
@toggle-mouse-mode="handleToggleMouseMode"
@update:video-mode="handleVideoModeChange"
@power-short="handlePowerShort"
@power-long="handlePowerLong"
@reset="handleReset"
@wol="handleWol"
@open-terminal="openTerminal"
/>
<!-- Main Video Area -->
<div class="flex-1 overflow-hidden relative">
<!-- Dot Pattern Background -->
<div
class="absolute inset-0 bg-slate-100/80 dark:bg-slate-800/40 opacity-80"
style="
background-image: radial-gradient(circle, rgb(148 163 184 / 0.4) 1px, transparent 1px);
background-size: 20px 20px;
"
/>
<!-- Video Container -->
<div class="relative h-full w-full flex items-center justify-center p-1 sm:p-4">
<div
ref="videoContainerRef"
class="relative bg-black overflow-hidden flex items-center justify-center"
:style="{
aspectRatio: videoAspectRatio ?? '16/9',
maxWidth: '100%',
maxHeight: '100%',
minHeight: '120px',
}"
:class="{
'opacity-60': videoLoading || videoError,
'cursor-crosshair': cursorVisible,
'cursor-none': !cursorVisible
}"
tabindex="0"
@mousemove="handleMouseMove"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@wheel.prevent="handleWheel"
@contextmenu="handleContextMenu"
>
<!-- MJPEG Stream -->
<img
v-show="videoMode === 'mjpeg'"
ref="videoRef"
:src="mjpegUrl"
class="w-full h-full object-contain"
:alt="t('console.videoAlt')"
@load="handleVideoLoad"
@error="handleVideoError"
/>
<!-- WebRTC Stream (H.264/H.265/VP8/VP9) -->
<!-- Note: muted is controlled by unifiedAudio, not hardcoded -->
<video
v-show="videoMode !== 'mjpeg'"
ref="webrtcVideoRef"
class="w-full h-full object-contain"
autoplay
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
v-if="videoLoading"
class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 backdrop-blur-sm transition-opacity duration-300"
>
<!-- Animated scan line for visual feedback -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute w-full h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" style="top: 50%; animation-duration: 1.5s;" />
</div>
<Spinner class="h-10 w-10 sm:h-16 sm:w-16 text-white mb-2 sm:mb-4" />
<p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4">
{{ webrtcLoadingMessage }}
</p>
<p class="text-white/50 text-xs sm:text-sm mt-1 sm:mt-2">
{{ t('console.pleaseWait') }}
</p>
</div>
</Transition>
<!--
Canonical 4-state signal overlay (no_signal / device_lost /
device_busy). Fully covers the video area with a solid dim
backdrop so the browser never shows a frozen last frame or a
transparent video element peeking through the MJPEG `<img>`
has its `src` cleared the moment the backend goes offline and
the WebRTC track is simply obscured. Sits below the loading /
error overlays so those take precedence when both apply.
-->
<Transition name="fade">
<div
v-if="showSignalOverlay && !videoLoading && !videoError"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-4 transition-opacity duration-300 pointer-events-none"
:class="{
'bg-black/80 backdrop-blur-sm': signalOverlayInfo.tone === 'error',
'bg-black/70 backdrop-blur-sm': signalOverlayInfo.tone !== 'error',
}"
>
<MonitorOff
class="h-10 w-10 sm:h-16 sm:w-16"
:class="{
'text-slate-200': signalOverlayInfo.tone === 'info',
'text-red-300': signalOverlayInfo.tone === 'error',
}"
/>
<div class="text-center max-w-md">
<p
class="font-semibold text-sm sm:text-lg text-white"
>{{ signalOverlayInfo.title }}</p>
<p
class="text-xs sm:text-sm mt-1 sm:mt-2"
:class="{
'text-slate-200/80': signalOverlayInfo.tone === 'info',
'text-red-100/80': signalOverlayInfo.tone === 'error',
}"
>{{ signalOverlayInfo.detail }}</p>
<p
v-if="signalOverlayInfo.hint"
class="text-[11px] sm:text-xs mt-2 text-white/50"
>{{ signalOverlayInfo.hint }}</p>
</div>
</div>
</Transition>
<!-- Error Overlay with smooth transition and detailed info -->
<Transition name="fade">
<div
v-if="videoError && !videoLoading"
class="absolute inset-0 flex flex-col items-center justify-center bg-black/85 text-white gap-4 transition-opacity duration-300 p-4"
>
<MonitorOff class="h-10 w-10 sm:h-16 sm:w-16 text-slate-400" />
<div class="text-center max-w-md px-2">
<p class="font-medium text-sm sm:text-lg mb-1 sm:mb-2">{{ t('console.connectionFailed') }}</p>
<p class="text-xs sm:text-sm text-slate-300 mb-2 sm:mb-3">{{ t('console.connectionFailedDesc') }}</p>
<!-- Expandable error details -->
<div v-if="videoErrorMessage" class="bg-slate-800/60 rounded-lg p-3 text-left">
<p class="text-xs text-slate-400 mb-1">{{ t('console.errorDetails') }}:</p>
<p class="text-sm text-slate-300 font-mono break-all">{{ videoErrorMessage }}</p>
</div>
</div>
<div class="flex gap-2">
<Button variant="secondary" size="sm" @click="reloadPage">
<RefreshCw class="h-4 w-4 mr-2" />
{{ t('console.reconnect') }}
</Button>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Virtual Keyboard - Above InfoBar when attached, or in body when floating -->
<Teleport :to="virtualKeyboardAttached ? '#keyboard-anchor' : 'body'" :disabled="virtualKeyboardAttached">
<VirtualKeyboard
v-if="virtualKeyboardVisible"
v-model:visible="virtualKeyboardVisible"
v-model:attached="virtualKeyboardAttached"
:caps-lock="keyboardLed.capsLock"
:pressed-keys="pressedKeys"
:consumer-enabled="virtualKeyboardConsumerEnabled"
@key-down="handleVirtualKeyDown"
@key-up="handleVirtualKeyUp"
/>
</Teleport>
<!-- Anchor for attached keyboard -->
<div id="keyboard-anchor"></div>
<!-- InfoBar (Status Bar) -->
<InfoBar
:pressed-keys="pressedKeys"
:caps-lock="keyboardLed.capsLock"
:num-lock="keyboardLed.numLock"
:scroll-lock="keyboardLed.scrollLock"
:keyboard-led-enabled="keyboardLedEnabled"
:mouse-position="mousePosition"
:debug-mode="false"
/>
<!-- Stats Sheet -->
<StatsSheet
v-model:open="statsSheetOpen"
:video-mode="videoMode"
:mjpeg-fps="backendFps"
:ws-latency="0"
:webrtc-stats="webrtc.stats.value"
/>
<!-- Terminal Dialog -->
<Dialog v-model:open="showTerminalDialog">
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
<DialogTitle class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
@click="openTerminalInNewTab"
:aria-label="t('extensions.ttyd.openInNewTab')"
:title="t('extensions.ttyd.openInNewTab')"
>
<ExternalLink class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
</DialogTitle>
</DialogHeader>
<div class="flex-1 min-h-0">
<iframe
v-if="showTerminalDialog"
src="/api/terminal/"
class="w-full h-full border-0"
allow="clipboard-read; clipboard-write"
scrolling="no"
/>
</div>
</DialogContent>
</Dialog>
<!-- Change Password Dialog -->
<Dialog v-model:open="changePasswordDialogOpen">
<DialogContent class="w-[95vw] max-w-md">
<DialogHeader>
<DialogTitle>{{ t('auth.changePassword') }}</DialogTitle>
</DialogHeader>
<div class="space-y-3 sm:space-y-4 py-2 sm:py-4">
<div class="space-y-2">
<Label for="currentPassword">{{ t('auth.currentPassword') }}</Label>
<Input
id="currentPassword"
v-model="currentPassword"
type="password"
:placeholder="t('auth.currentPasswordPlaceholder')"
/>
</div>
<div class="space-y-2">
<Label for="newPassword">{{ t('auth.newPassword') }}</Label>
<Input
id="newPassword"
v-model="newPassword"
type="password"
:placeholder="t('auth.newPasswordPlaceholder')"
/>
</div>
<div class="space-y-2">
<Label for="confirmPassword">{{ t('auth.confirmPassword') }}</Label>
<Input
id="confirmPassword"
v-model="confirmPassword"
type="password"
:placeholder="t('auth.confirmPasswordPlaceholder')"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="changePasswordDialogOpen = false">
{{ t('common.cancel') }}
</Button>
<Button @click="handleChangePassword" :disabled="changingPassword">
<Loader2 v-if="changingPassword" class="h-4 w-4 mr-2 animate-spin" />
{{ t('common.confirm') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<style scoped>
/* Smooth fade transition for video overlays */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>